Initial module import #1

Merged
Sebastian merged 5 commits from initial-module-import into main 2026-09-14 22:09:13 +00:00
21 changed files with 5420 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
name: Build Test
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Retrieve Server Install Action
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/action-server-install
ref: main
path: action-server-install
github-server-url: https://git.ktrix.dev
- name: Install Server
uses: ./action-server-install
with:
install-php: 'false'
install-node: 'true'
php-version: '8.5'
node-version: '24'
server-path: './server'
- name: Install Documents Manager
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/documents_manager
ref: main
path: server/modules/documents_manager
github-server-url: https://git.ktrix.dev
- name: Checkout Pull Request
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.repository }}
ref: ${{ github.event.pull_request.head.sha }}
path: server/modules/provider_editor_text
github-server-url: https://git.ktrix.dev
- name: Install dependencies
run: npm ci
working-directory: server/modules/provider_editor_text
- name: Build
run: npm run build
working-directory: server/modules/provider_editor_text
+50
View File
@@ -0,0 +1,50 @@
name: JS Unit Tests
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Retrieve Server Install Action
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/action-server-install
ref: main
path: action-server-install
github-server-url: https://git.ktrix.dev
- name: Install Server
uses: ./action-server-install
with:
install-php: 'false'
install-node: 'true'
php-version: '8.5'
node-version: '24'
server-path: './server'
- name: Install Documents Manager
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/documents_manager
ref: main
path: server/modules/documents_manager
github-server-url: https://git.ktrix.dev
- name: Checkout Pull Request
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.repository }}
ref: ${{ github.event.pull_request.head.sha }}
path: server/modules/provider_editor_text
github-server-url: https://git.ktrix.dev
- name: Install dependencies
run: npm ci
working-directory: server/modules/provider_editor_text
- name: Run tests
run: npm run test:unit
working-directory: server/modules/provider_editor_text
+41
View File
@@ -0,0 +1,41 @@
name: Renovate
on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
jobs:
renovate:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Set up Node.js
uses: actions/setup-node@v6.2.0
with:
node-version: 24
cache: npm
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.5'
tools: composer:v2
- name: Install Renovate
run: |
npm install --global --no-audit --fund=false \
--prefix "${{ runner.temp }}/renovate-npm" \
--cache "${{ runner.temp }}/renovate-npm-cache" \
renovate
"${{ runner.temp }}/renovate-npm/bin/renovate" --version
- name: Run Renovate
env:
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
RENOVATE_PLATFORM: gitea
RENOVATE_ENDPOINT: https://git.ktrix.dev/api/v1
run: |
"${{ runner.temp }}/renovate-npm/bin/renovate" ${{ gitea.repository }}
+27
View File
@@ -0,0 +1,27 @@
# Frontend development
node_modules/
*.local
.env.local
.env.*.local
.cache/
.vite/
.temp/
.tmp/
# Frontend build
/static/
# Backend development
/vendor/
/lib/vendor/
coverage/
*.cache
.phpactor/
# Editors
.DS_Store
.vscode/
.idea/
# Logs
*.log
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace KTXM\ProviderEditorText;
use KTXF\Module\Configuration\BrowserModuleContextInterface;
use KTXF\Module\Configuration\ModuleContextInterface;
use KTXF\Module\ModuleInstanceAbstract;
/**
* Text Editor Provider — provides inline editing for text, Markdown, and HTML files
* via the documents_editor integration point.
*/
class Module extends ModuleInstanceAbstract
{
public function __construct()
{ }
public function handle(): string
{
return 'provider_editor_text';
}
public function label(): string
{
return 'Text Editor Provider';
}
public function author(): string
{
return 'Ktrix';
}
public function description(): string
{
return 'Provides inline text, Markdown, and HTML editing for the Documents module via the file editor integration point.';
}
public function version(): string
{
return '0.0.1';
}
public function permissions(): array
{
return [];
}
public function configure(ModuleContextInterface $context): void
{
if ($context instanceof BrowserModuleContextInterface) {
$context->registerModule($this, 'ProviderEditorText', 'static/module.mjs');
}
}
}
+4203
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
{
"name": "provider_editor_text",
"version": "0.0.1",
"private": true,
"license": "AGPL-3.0-or-later",
"author": "Ktrix",
"type": "module",
"scripts": {
"build": "vite build --mode production --config vite.config.ts",
"dev": "vite build --mode development --config vite.config.ts",
"watch": "vite build --mode development --watch --config vite.config.ts",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"test": "vitest run --config tests/js/vitest.config.ts",
"test:unit": "vitest run --config tests/js/vitest.config.ts",
"test:watch": "vitest watch --config tests/js/vitest.config.ts",
"test:coverage": "vitest run --coverage --config tests/js/vitest.config.ts"
},
"dependencies": {
"@tiptap/vue-3": "^2.11.5",
"@tiptap/pm": "^2.11.5",
"@tiptap/starter-kit": "^2.11.5",
"@tiptap/extension-link": "^2.11.5",
"@tiptap/extension-underline": "^2.11.5",
"@tiptap/extension-placeholder": "^2.11.5",
"@tiptap/extension-text-align": "^2.11.5",
"tiptap-markdown": "^0.8.10",
"pinia": "^2.3.1",
"vue": "^3.5.18",
"vue-router": "^4.5.1",
"vuetify": "^3.10.2"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.7.0",
"typescript": "~5.8.3",
"vite": "^7.1.2",
"vue-tsc": "^3.0.5",
"@vitest/coverage-v8": "^4.1.6",
"@vue/test-utils": "^2.4.10",
"jsdom": "^29.1.1",
"vitest": "^4.1.6"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"enabledManagers": ["npm", "composer", "github-actions"],
"timezone": "UTC",
"schedule": ["* 0-3 * * *"],
"dependencyDashboard": true,
"prConcurrentLimit": 5,
"prHourlyLimit": 2
}
+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_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 }
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest'
describe('Basic Tests', () => {
it('should perform basic assertion', () => {
expect(true).toBe(true)
})
it('should test array operations', () => {
const array = ['foo', 'bar', 'baz']
expect(array).toHaveLength(3)
expect(array).toContain('bar')
expect(array[0]).toBe('foo')
})
it('should test string operations', () => {
const string = 'Hello, World!'
expect(string).toContain('World')
expect(string.length).toBe(13)
})
it('should test object operations', () => {
const obj = { foo: 'bar', count: 42 }
expect(obj).toHaveProperty('foo')
expect(obj.foo).toBe('bar')
expect(obj.count).toBeGreaterThan(40)
})
})
+33
View File
@@ -0,0 +1,33 @@
import { fileURLToPath } from 'node:url'
import { defineConfig, configDefaults } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import path from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, '../../src'),
'@KTXC': path.resolve(__dirname, '../../../../core/src'),
},
},
test: {
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('../../', import.meta.url)),
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'tests/',
'**/*.d.ts',
'**/*.config.*',
'**/dist/**',
],
},
},
})
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.app.json",
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": {
"@/*": ["./src/*"],
"@KTXC/*": ["../../core/src/*"],
"@DocumentsManager/*": ["../documents_manager/src/*"]
}
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"../documents_manager/src/**/*.ts",
"../../core/src/**/*.ts"
]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.node.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
},
"include": [
"vite.config.ts"
]
}
+70
View File
@@ -0,0 +1,70 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
{
name: 'inject-css-filename',
enforce: 'post',
generateBundle(_options, bundle) {
const cssFile = Object.keys(bundle).find(name => name.endsWith('.css'))
if (!cssFile) return
for (const fileName of Object.keys(bundle)) {
const chunk = bundle[fileName]
if (chunk.type === 'chunk' && chunk.code.includes('__CSS_FILENAME_PLACEHOLDER__')) {
chunk.code = chunk.code.replace(/__CSS_FILENAME_PLACEHOLDER__/g, `static/${cssFile}`)
console.log(`Injected CSS filename "static/${cssFile}" into ${fileName}`)
}
}
}
}
],
define: {
'process.env.NODE_ENV': JSON.stringify('production'),
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@KTXC': path.resolve(__dirname, '../../core/src'),
'@DocumentsManager': path.resolve(__dirname, '../documents_manager/src'),
},
},
build: {
outDir: 'static',
emptyOutDir: true,
sourcemap: true,
lib: {
entry: path.resolve(__dirname, 'src/main.ts'),
formats: ['es'],
fileName: () => 'module.mjs',
},
rollupOptions: {
external: [
'vue',
'vue-router',
'pinia',
'@KTXC',
/^@DocumentsManager\//,
],
output: {
paths: (id) => {
if (id === '@KTXC') return '/js/ktxc.mjs'
if (id.startsWith('@DocumentsManager/')) {
return '/modules/documents_manager/static/module.mjs'
}
return id
},
assetFileNames: (assetInfo) => {
if (assetInfo.name?.endsWith('.css')) {
return 'provider-editor-text-[hash].css'
}
return '[name]-[hash][extname]'
}
}
},
},
})