chore: implement js test suites

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-07-24 00:14:15 -04:00
commit 8799ad0e70
21 changed files with 5409 additions and 0 deletions
+236
View File
@@ -0,0 +1,236 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Editor } from '@tiptap/vue-3'
defineProps<{
editor: Editor | null
isMarkdown: boolean
sourceMode: boolean
isDirty: boolean
isSaving: boolean
showRichControls: boolean
}>()
const emit = defineEmits<{
save: []
'toggle-mode': [toSource: boolean]
}>()
// Link dialog state
const showLinkDialog = ref(false)
const linkUrl = ref('')
function openLinkDialog(editor: Editor) {
const existing = editor.getAttributes('link').href as string | undefined
linkUrl.value = existing ?? ''
showLinkDialog.value = true
}
function applyLink(editor: Editor) {
showLinkDialog.value = false
const url = linkUrl.value.trim()
if (url) {
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
} else {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
}
linkUrl.value = ''
}
</script>
<template>
<div class="editor-toolbar-strip">
<!-- Rich text formatting controls -->
<template v-if="showRichControls && editor">
<v-btn-group density="compact" variant="text">
<v-btn
size="small"
icon="mdi-format-bold"
:active="editor.isActive('bold')"
@click="editor.chain().focus().toggleBold().run()"
/>
<v-btn
size="small"
icon="mdi-format-italic"
:active="editor.isActive('italic')"
@click="editor.chain().focus().toggleItalic().run()"
/>
<v-btn
size="small"
icon="mdi-format-underline"
:active="editor.isActive('underline')"
@click="editor.chain().focus().toggleUnderline().run()"
/>
<v-btn
size="small"
icon="mdi-format-strikethrough"
:active="editor.isActive('strike')"
@click="editor.chain().focus().toggleStrike().run()"
/>
</v-btn-group>
<v-divider vertical class="mx-1" />
<v-btn-group density="compact" variant="text">
<v-btn
size="small"
icon="mdi-format-header-1"
:active="editor.isActive('heading', { level: 1 })"
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
/>
<v-btn
size="small"
icon="mdi-format-header-2"
:active="editor.isActive('heading', { level: 2 })"
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
/>
<v-btn
size="small"
icon="mdi-format-header-3"
:active="editor.isActive('heading', { level: 3 })"
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
/>
</v-btn-group>
<v-divider vertical class="mx-1" />
<v-btn-group density="compact" variant="text">
<v-btn
size="small"
icon="mdi-format-list-bulleted"
:active="editor.isActive('bulletList')"
@click="editor.chain().focus().toggleBulletList().run()"
/>
<v-btn
size="small"
icon="mdi-format-list-numbered"
:active="editor.isActive('orderedList')"
@click="editor.chain().focus().toggleOrderedList().run()"
/>
<v-btn
size="small"
icon="mdi-format-quote-close"
:active="editor.isActive('blockquote')"
@click="editor.chain().focus().toggleBlockquote().run()"
/>
<v-btn
size="small"
icon="mdi-code-tags"
:active="editor.isActive('codeBlock')"
@click="editor.chain().focus().toggleCodeBlock().run()"
/>
</v-btn-group>
<v-divider vertical class="mx-1" />
<v-btn-group density="compact" variant="text">
<v-btn
size="small"
icon="mdi-link"
:active="editor.isActive('link')"
@click="openLinkDialog(editor)"
/>
<v-btn
size="small"
icon="mdi-minus"
@click="editor.chain().focus().setHorizontalRule().run()"
/>
</v-btn-group>
<v-divider vertical class="mx-1" />
<v-btn-group density="compact" variant="text">
<v-btn
size="small"
icon="mdi-undo"
:disabled="!editor.can().undo()"
@click="editor.chain().focus().undo().run()"
/>
<v-btn
size="small"
icon="mdi-redo"
:disabled="!editor.can().redo()"
@click="editor.chain().focus().redo().run()"
/>
</v-btn-group>
</template>
<v-spacer />
<!-- Mode toggle for markdown -->
<v-btn-group v-if="isMarkdown" density="compact" variant="text" class="mr-2">
<v-btn
size="small"
:active="!sourceMode"
@click="emit('toggle-mode', false)"
>
<v-icon start size="small">mdi-eye</v-icon>
Visual
</v-btn>
<v-btn
size="small"
:active="sourceMode"
@click="emit('toggle-mode', true)"
>
<v-icon start size="small">mdi-code-tags</v-icon>
Source
</v-btn>
</v-btn-group>
<!-- Save button -->
<v-btn
variant="tonal"
color="primary"
size="small"
:disabled="!isDirty || isSaving"
:loading="isSaving"
@click="emit('save')"
>
<v-icon start size="small">mdi-content-save</v-icon>
Save
</v-btn>
</div>
<!-- Link dialog -->
<v-dialog v-model="showLinkDialog" max-width="400">
<v-card>
<v-card-title class="text-body-1">Insert Link</v-card-title>
<v-card-text>
<v-text-field
v-model="linkUrl"
label="URL"
placeholder="https://example.com"
variant="outlined"
density="compact"
autofocus
@keydown.enter="editor && applyLink(editor)"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showLinkDialog = false">Cancel</v-btn>
<v-btn
color="primary"
variant="tonal"
:disabled="!editor"
@click="editor && applyLink(editor)"
>
Apply
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
.editor-toolbar-strip {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
min-height: 44px;
flex-shrink: 0;
overflow-x: auto;
}
</style>
+174
View File
@@ -0,0 +1,174 @@
<script setup lang="ts">
import { onBeforeUnmount, watch } from 'vue'
import { useEditor, EditorContent, type Editor } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Underline from '@tiptap/extension-underline'
import TextAlign from '@tiptap/extension-text-align'
import Placeholder from '@tiptap/extension-placeholder'
import { Markdown } from 'tiptap-markdown'
const props = defineProps<{
mime: string
fileExtension: string
}>()
const emit = defineEmits<{
update: []
'editor-ready': [editor: Editor]
}>()
const isMarkdown = props.mime === 'text/markdown' || props.mime === 'text/x-markdown' ||
props.fileExtension === 'md' || props.fileExtension === 'markdown'
const editor = useEditor({
onCreate: ({ editor: e }) => {
emit('editor-ready', e)
},
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3, 4, 5, 6] },
}),
Link.configure({
openOnClick: false,
HTMLAttributes: { rel: 'noopener noreferrer nofollow' },
}),
Underline,
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
Placeholder.configure({
placeholder: 'Start typing…',
}),
...(isMarkdown
? [
Markdown.configure({
html: false,
transformPastedText: true,
transformCopiedText: true,
}),
]
: []),
],
onUpdate: () => {
emit('update')
},
})
function setContent(content: string) {
if (!editor.value) return
editor.value.commands.setContent(content, false)
}
function getContent(): string {
if (!editor.value) return ''
if (isMarkdown) {
return editor.value.storage.markdown.getMarkdown()
}
return editor.value.getHTML()
}
// Clean up
onBeforeUnmount(() => {
editor.value?.destroy()
})
// Watch for editor recreation (shouldn't happen, but safety net)
watch(editor, (newEditor, oldEditor) => {
if (oldEditor && oldEditor !== newEditor) {
oldEditor.destroy()
}
})
defineExpose({
editor,
setContent,
getContent,
})
</script>
<template>
<div class="rich-text-editor">
<EditorContent v-if="editor" :editor="editor" class="rich-text-content" />
</div>
</template>
<style scoped>
.rich-text-editor {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.rich-text-content {
flex: 1;
overflow-y: auto;
}
.rich-text-content :deep(.tiptap) {
padding: 16px 24px;
outline: none;
min-height: 100%;
font-size: 0.9375rem;
line-height: 1.7;
}
.rich-text-content :deep(.tiptap p.is-editor-empty:first-child::before) {
color: rgba(var(--v-theme-on-surface), 0.4);
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
/* Headings */
.rich-text-content :deep(.tiptap h1) { font-size: 2rem; margin: 1em 0 0.5em; }
.rich-text-content :deep(.tiptap h2) { font-size: 1.5rem; margin: 1em 0 0.5em; }
.rich-text-content :deep(.tiptap h3) { font-size: 1.25rem; margin: 1em 0 0.5em; }
/* Blockquote */
.rich-text-content :deep(.tiptap blockquote) {
border-left: 3px solid rgba(var(--v-border-color), var(--v-border-opacity));
padding-left: 16px;
margin: 0.5em 0;
color: rgba(var(--v-theme-on-surface), 0.7);
}
/* Code blocks */
.rich-text-content :deep(.tiptap pre) {
background: rgba(var(--v-theme-on-surface), 0.05);
border-radius: 4px;
padding: 12px 16px;
font-family: 'Roboto Mono', monospace;
font-size: 0.875rem;
overflow-x: auto;
}
.rich-text-content :deep(.tiptap code) {
background: rgba(var(--v-theme-on-surface), 0.08);
border-radius: 3px;
padding: 2px 4px;
font-family: 'Roboto Mono', monospace;
font-size: 0.875rem;
}
/* Horizontal rule */
.rich-text-content :deep(.tiptap hr) {
border: none;
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
margin: 1em 0;
}
/* Links */
.rich-text-content :deep(.tiptap a) {
color: rgb(var(--v-theme-primary));
text-decoration: underline;
}
/* Lists */
.rich-text-content :deep(.tiptap ul),
.rich-text-content :deep(.tiptap ol) {
padding-left: 24px;
}
</style>
+94
View File
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
const emit = defineEmits<{
update: []
}>()
const content = ref('')
const textareaRef = ref<HTMLTextAreaElement | null>(null)
function setContent(value: string) {
content.value = value
}
function getContent(): string {
return content.value
}
function handleInput() {
emit('update')
}
function handleKeydown(e: KeyboardEvent) {
// Tab inserts 2 spaces instead of changing focus
if (e.key === 'Tab') {
e.preventDefault()
const textarea = textareaRef.value
if (!textarea) return
const start = textarea.selectionStart
const end = textarea.selectionEnd
const indent = ' '
content.value =
content.value.substring(0, start) + indent + content.value.substring(end)
// Restore cursor position
nextTick(() => {
textarea.selectionStart = textarea.selectionEnd = start + indent.length
})
emit('update')
}
}
onMounted(() => {
textareaRef.value?.focus()
})
defineExpose({
setContent,
getContent,
})
</script>
<template>
<div class="source-editor">
<textarea
ref="textareaRef"
v-model="content"
class="source-textarea"
spellcheck="false"
autocomplete="off"
autocapitalize="off"
@input="handleInput"
@keydown="handleKeydown"
/>
</div>
</template>
<style scoped>
.source-editor {
flex: 1;
display: flex;
overflow: hidden;
}
.source-textarea {
flex: 1;
width: 100%;
padding: 16px 24px;
border: none;
outline: none;
resize: none;
background: transparent;
color: rgb(var(--v-theme-on-surface));
font-family: 'Roboto Mono', 'Courier New', monospace;
font-size: 0.875rem;
line-height: 1.6;
tab-size: 2;
white-space: pre;
overflow: auto;
}
</style>
+234
View File
@@ -0,0 +1,234 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, shallowRef, watch, nextTick } from 'vue'
import type { EntityObject } from '@DocumentsManager/models/entity'
import RichTextEditor from './RichTextEditor.vue'
import SourceEditor from './SourceEditor.vue'
import EditorToolbar from './EditorToolbar.vue'
const props = defineProps<{
entity: EntityObject
mime: string
readFile: (entityId: string) => Promise<string | null>
writeFile: (entityId: string, content: string) => Promise<number>
}>()
const emit = defineEmits<{
dirty: [value: boolean]
}>()
// --- State ---
const loading = ref(true)
const saving = ref(false)
const error = ref<string | null>(null)
const isDirty = ref(false)
const lastSavedContent = ref('')
// For markdown: toggle between visual (rich) and source mode
const sourceMode = ref(false)
// References to child editors
const richEditorRef = ref<InstanceType<typeof RichTextEditor> | null>(null)
const sourceEditorRef = ref<InstanceType<typeof SourceEditor> | null>(null)
// TipTap editor instance, set when RichTextEditor emits it
const tiptapEditor = shallowRef<any>(null)
// --- Computed ---
// Detect markdown by MIME type or file extension (servers often report .md as text/plain)
const fileExtension = computed(() => {
const label = props.entity?.properties?.label ?? ''
const dot = label.lastIndexOf('.')
return dot >= 0 ? label.substring(dot + 1).toLowerCase() : ''
})
const isMarkdown = computed(() =>
props.mime === 'text/markdown' || props.mime === 'text/x-markdown' ||
fileExtension.value === 'md' || fileExtension.value === 'markdown',
)
const isHtml = computed(() => props.mime === 'text/html' || fileExtension.value === 'html' || fileExtension.value === 'htm')
const isPlainText = computed(() => !isMarkdown.value && !isHtml.value)
// Show rich editor for HTML always, and for markdown when not in source mode
const showRichEditor = computed(() => isHtml.value || (isMarkdown.value && !sourceMode.value))
const showSourceEditor = computed(() => isPlainText.value || (isMarkdown.value && sourceMode.value))
// --- Helpers ---
function decodeBase64(base64: string): string {
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0))
return new TextDecoder().decode(bytes)
}
function encodeBase64(text: string): string {
const bytes = new TextEncoder().encode(text)
let binary = ''
for (const byte of bytes) {
binary += String.fromCharCode(byte)
}
return btoa(binary)
}
// --- Content loading ---
async function loadContent() {
loading.value = true
error.value = null
try {
const raw = await props.readFile(String(props.entity.identifier))
const decoded = raw ? decodeBase64(raw) : ''
lastSavedContent.value = decoded
isDirty.value = false
emit('dirty', false)
// Set loading false first so editors render, then set content after they mount
loading.value = false
await nextTick()
setEditorContent(decoded)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load file'
loading.value = false
}
}
function setEditorContent(content: string) {
if (showRichEditor.value && richEditorRef.value) {
richEditorRef.value.setContent(content)
} else if (showSourceEditor.value && sourceEditorRef.value) {
sourceEditorRef.value.setContent(content)
}
}
function getEditorContent(): string {
if (showRichEditor.value && richEditorRef.value) {
return richEditorRef.value.getContent()
}
if (showSourceEditor.value && sourceEditorRef.value) {
return sourceEditorRef.value.getContent()
}
return lastSavedContent.value
}
// --- Save ---
async function handleSave() {
if (!isDirty.value || saving.value) return
saving.value = true
try {
const content = getEditorContent()
const encoded = encodeBase64(content)
await props.writeFile(String(props.entity.identifier), encoded)
lastSavedContent.value = content
isDirty.value = false
emit('dirty', false)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to save file'
} finally {
saving.value = false
}
}
// --- Dirty tracking ---
function handleContentChange() {
const current = getEditorContent()
const dirty = current !== lastSavedContent.value
if (dirty !== isDirty.value) {
isDirty.value = dirty
emit('dirty', dirty)
}
}
// --- Mode switch (markdown only) ---
function handleModeToggle(toSource: boolean) {
// Serialize from current editor before switching
const content = getEditorContent()
sourceMode.value = toSource
tiptapEditor.value = null
nextTick(() => {
setEditorContent(content)
})
}
// --- Keyboard shortcut: Ctrl/Cmd+S ---
function handleKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
handleSave()
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
loadContent()
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
// Reload when entity changes
watch(
() => props.entity.identifier,
() => {
sourceMode.value = false
loadContent()
},
)
</script>
<template>
<div class="text-editor-viewer">
<!-- Toolbar -->
<EditorToolbar
:editor="tiptapEditor"
:is-markdown="isMarkdown"
:source-mode="sourceMode"
:is-dirty="isDirty"
:is-saving="saving"
:show-rich-controls="showRichEditor"
@save="handleSave"
@toggle-mode="handleModeToggle"
/>
<!-- Loading -->
<div v-if="loading" class="editor-state-overlay">
<v-progress-circular indeterminate color="primary" />
</div>
<!-- Error -->
<div v-else-if="error" class="editor-state-overlay">
<v-icon size="48" color="error">mdi-alert-circle-outline</v-icon>
<p class="text-body-1 mt-3 text-error">{{ error }}</p>
<v-btn variant="tonal" class="mt-3" @click="loadContent">Retry</v-btn>
</div>
<!-- Editor -->
<template v-else>
<RichTextEditor
v-if="showRichEditor"
ref="richEditorRef"
:mime="mime"
:file-extension="fileExtension"
@update="handleContentChange"
@editor-ready="tiptapEditor = $event"
/>
<SourceEditor
v-if="showSourceEditor"
ref="sourceEditorRef"
@update="handleContentChange"
/>
</template>
</div>
</template>
<style scoped>
.text-editor-viewer {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.editor-state-overlay {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
</style>
+4
View File
@@ -0,0 +1,4 @@
export { default as TextEditorViewer } from './TextEditorViewer.vue'
export { default as RichTextEditor } from './RichTextEditor.vue'
export { default as SourceEditor } from './SourceEditor.vue'
export { default as EditorToolbar } from './EditorToolbar.vue'
+23
View File
@@ -0,0 +1,23 @@
import type { ModuleIntegrations } from '@KTXC/types/moduleTypes'
const integrations: ModuleIntegrations = {
/**
* Register the text editor for text/plain, text/markdown, and text/html MIME types.
*
* Editor component contract:
* props: { entity: EntityObject, mime: string, readFile: Function, writeFile: Function }
*/
documents_file_editor: [
{
id: 'text_editor',
label: 'Text Editor',
priority: 20,
meta: {
mimeTypes: ['text/plain', 'text/markdown', 'text/x-markdown', 'text/html'],
},
component: () => import('./components/TextEditorViewer.vue'),
},
],
}
export default integrations
+7
View File
@@ -0,0 +1,7 @@
import integrations from '@/integrations'
// CSS filename is injected by the vite plugin at build time
// The placeholder gets replaced with the actual hashed filename
export const css = ['__CSS_FILENAME_PLACEHOLDER__']
export { integrations }