generated from Nodarx/template
chore: implement js test suites
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
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: Checkout Pull Request
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
path: server/modules/documents_text_editor
|
||||
github-server-url: https://git.ktrix.dev
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: server/modules/documents_text_editor
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
working-directory: server/modules/documents_text_editor
|
||||
@@ -0,0 +1,42 @@
|
||||
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: Checkout Pull Request
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
path: server/modules/documents_text_editor
|
||||
github-server-url: https://git.ktrix.dev
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: server/modules/documents_text_editor
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test:unit
|
||||
working-directory: server/modules/documents_text_editor
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace KTXM\DocumentsTextEditor;
|
||||
|
||||
use KTXF\Module\ModuleBrowserInterface;
|
||||
use KTXF\Module\ModuleInstanceAbstract;
|
||||
|
||||
/**
|
||||
* Documents Text Editor — provides inline editing for text, Markdown, and HTML files
|
||||
* via the documents_file_editor integration point.
|
||||
*/
|
||||
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
||||
{
|
||||
|
||||
public function __construct()
|
||||
{ }
|
||||
|
||||
public function handle(): string
|
||||
{
|
||||
return 'documents_text_editor';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Documents Text Editor';
|
||||
}
|
||||
|
||||
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 registerBI(): array
|
||||
{
|
||||
return [
|
||||
'handle' => $this->handle(),
|
||||
'namespace' => 'DocumentsTextEditor',
|
||||
'version' => $this->version(),
|
||||
'label' => $this->label(),
|
||||
'author' => $this->author(),
|
||||
'description' => $this->description(),
|
||||
'boot' => 'static/module.mjs',
|
||||
];
|
||||
}
|
||||
}
|
||||
Generated
+4203
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "documents_text_editor",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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/**',
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.node.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
@@ -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 'documents-text-editor-[hash].css'
|
||||
}
|
||||
return '[name]-[hash][extname]'
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user