@michaelthielemann/kestrel 4.1.2 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -201
- package/dist/boot.d.ts +45 -0
- package/dist/boot.js +274 -0
- package/dist/cast.d.ts +3 -0
- package/dist/cast.js +6 -0
- package/dist/catalogue.d.ts +11 -0
- package/dist/catalogue.js +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +55 -0
- package/dist/context.d.ts +62 -0
- package/dist/context.js +61 -0
- package/dist/dataflow.d.ts +3 -0
- package/dist/dataflow.js +65 -0
- package/dist/defineConfig.d.ts +157 -0
- package/dist/defineConfig.js +41 -0
- package/dist/defineContract.d.ts +12 -0
- package/dist/defineContract.js +16 -0
- package/dist/defineModule.d.ts +75 -0
- package/dist/defineModule.js +9 -0
- package/dist/definePipeline.d.ts +7 -0
- package/dist/definePipeline.js +11 -0
- package/dist/errors.d.ts +26 -0
- package/dist/errors.js +48 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +14 -0
- package/dist/load.d.ts +7 -0
- package/dist/load.js +79 -0
- package/dist/logger.d.ts +18 -0
- package/dist/logger.js +36 -0
- package/dist/registry.d.ts +14 -0
- package/dist/registry.js +87 -0
- package/dist/result.d.ts +19 -0
- package/dist/result.js +19 -0
- package/dist/runner.d.ts +29 -0
- package/dist/runner.js +133 -0
- package/dist/schema.d.ts +7 -0
- package/dist/schema.js +142 -0
- package/dist/sort.d.ts +2 -0
- package/dist/sort.js +50 -0
- package/dist/testing/runPipeline.d.ts +9 -0
- package/dist/testing/runPipeline.js +22 -0
- package/dist/triggers/cron.d.ts +10 -0
- package/dist/triggers/cron.js +44 -0
- package/dist/triggers/cronMatch.d.ts +10 -0
- package/dist/triggers/cronMatch.js +68 -0
- package/dist/triggers/http.d.ts +58 -0
- package/dist/triggers/http.js +292 -0
- package/package.json +25 -151
- package/NOTICE +0 -12
- package/kestrel.config.ts +0 -28
- package/layers/access/nuxt.config.ts +0 -5
- package/layers/access/server/middleware/00.ip-allowlist.ts +0 -52
- package/layers/access/server/middleware/access-guard.ts +0 -32
- package/layers/access/server/types.d.ts +0 -7
- package/layers/admin/app/components/AdminAccount.vue +0 -212
- package/layers/admin/app/components/AdminNav.vue +0 -44
- package/layers/admin/app/components/BlockFields.vue +0 -67
- package/layers/admin/app/components/BlockPreview.vue +0 -413
- package/layers/admin/app/components/BlockTree.vue +0 -433
- package/layers/admin/app/components/BlocksBody.vue +0 -163
- package/layers/admin/app/components/CollectionDeleteDialog.vue +0 -38
- package/layers/admin/app/components/CollectionEditor.vue +0 -324
- package/layers/admin/app/components/CollectionList.vue +0 -429
- package/layers/admin/app/components/CollectionListBulkBar.vue +0 -62
- package/layers/admin/app/components/CollectionListColumnsPanel.vue +0 -37
- package/layers/admin/app/components/CollectionListFilterPanel.vue +0 -145
- package/layers/admin/app/components/CollectionListPager.vue +0 -70
- package/layers/admin/app/components/CollectionListTable.vue +0 -347
- package/layers/admin/app/components/EditorStatus.vue +0 -211
- package/layers/admin/app/components/EditorUnsupported.vue +0 -28
- package/layers/admin/app/components/FieldsBody.vue +0 -23
- package/layers/admin/app/components/LocaleBar.vue +0 -107
- package/layers/admin/app/components/PageFields.vue +0 -129
- package/layers/admin/app/components/PageFieldsPane.vue +0 -39
- package/layers/admin/app/components/SeoFields.vue +0 -210
- package/layers/admin/app/components/SingletonEditor.vue +0 -93
- package/layers/admin/app/composables/useAuth.ts +0 -68
- package/layers/admin/app/composables/useBlockTree.ts +0 -95
- package/layers/admin/app/composables/useBlocks.ts +0 -7
- package/layers/admin/app/composables/useCachedResource.ts +0 -35
- package/layers/admin/app/composables/useCollectionOps.ts +0 -102
- package/layers/admin/app/composables/useCollections.ts +0 -6
- package/layers/admin/app/composables/useContentLocales.ts +0 -23
- package/layers/admin/app/composables/useEditForm.ts +0 -402
- package/layers/admin/app/composables/useLinkResolver.ts +0 -40
- package/layers/admin/app/composables/useListBatchActions.ts +0 -78
- package/layers/admin/app/composables/useListColumns.ts +0 -53
- package/layers/admin/app/composables/useListFilterDraft.ts +0 -129
- package/layers/admin/app/composables/useListRows.ts +0 -75
- package/layers/admin/app/composables/useListSelection.ts +0 -28
- package/layers/admin/app/composables/useListUrlState.ts +0 -53
- package/layers/admin/app/composables/usePendingPublishPoll.ts +0 -15
- package/layers/admin/app/composables/usePreviewViewport.ts +0 -30
- package/layers/admin/app/composables/usePublishStatus.ts +0 -107
- package/layers/admin/app/composables/useRailCollapsed.ts +0 -14
- package/layers/admin/app/composables/useTheme.ts +0 -36
- package/layers/admin/app/composables/useToolbarPanel.ts +0 -38
- package/layers/admin/app/composables/useUnsavedGuard.ts +0 -22
- package/layers/admin/app/layouts/admin.vue +0 -266
- package/layers/admin/app/middleware/admin-auth.ts +0 -4
- package/layers/admin/app/pages/admin/[collection]/[id].vue +0 -203
- package/layers/admin/app/pages/admin/[collection]/index.vue +0 -62
- package/layers/admin/app/pages/admin/index.vue +0 -118
- package/layers/admin/app/pages/admin/login.vue +0 -68
- package/layers/admin/app/pages/admin/media.vue +0 -28
- package/layers/admin/app/pages/admin/references.vue +0 -146
- package/layers/admin/app/plugins/admin-style-guard.client.ts +0 -76
- package/layers/admin/app/plugins/reauth.client.ts +0 -20
- package/layers/admin/app/utils/admin-style-guard.ts +0 -20
- package/layers/admin/app/utils/block-tree.ts +0 -214
- package/layers/admin/app/utils/collection-ops.ts +0 -69
- package/layers/admin/app/utils/dead-refs.ts +0 -30
- package/layers/admin/app/utils/edit-form.ts +0 -267
- package/layers/admin/app/utils/editor-expose.ts +0 -36
- package/layers/admin/app/utils/editor-form-context.ts +0 -82
- package/layers/admin/app/utils/editor-registry.ts +0 -20
- package/layers/admin/app/utils/field-keys.ts +0 -11
- package/layers/admin/app/utils/list-cell.ts +0 -46
- package/layers/admin/app/utils/list-columns.ts +0 -142
- package/layers/admin/app/utils/list-query.ts +0 -96
- package/layers/admin/app/utils/nav-active.ts +0 -9
- package/layers/admin/app/utils/populate-blocks.ts +0 -288
- package/layers/admin/app/utils/preview-channel.ts +0 -62
- package/layers/admin/app/utils/preview-viewport.ts +0 -80
- package/layers/admin/app/utils/reauth.ts +0 -67
- package/layers/admin/app/utils/record-title.ts +0 -15
- package/layers/admin/app/utils/register-builtin-editors.ts +0 -9
- package/layers/admin/app/utils/safe-redirect.ts +0 -12
- package/layers/admin/nuxt.config.ts +0 -20
- package/layers/auth/nuxt.config.ts +0 -1
- package/layers/auth/server/plugins/01.register-auth-pipelines.ts +0 -7
- package/layers/collections/nuxt.config.ts +0 -1
- package/layers/collections/server/plugins/01.register.ts +0 -29
- package/layers/collections/server/plugins/02.register-relation-populate.ts +0 -18
- package/layers/core/app/composables/layouts.ts +0 -5
- package/layers/core/app/utils/field-factories.ts +0 -129
- package/layers/core/app/utils/layouts.ts +0 -38
- package/layers/core/modules/auto-discovery/extract-block.ts +0 -179
- package/layers/core/modules/auto-discovery/index.ts +0 -112
- package/layers/core/modules/auto-discovery/package-registry.ts +0 -19
- package/layers/core/modules/auto-discovery/scan.ts +0 -170
- package/layers/core/modules/auto-discovery/virtual.d.ts +0 -20
- package/layers/core/modules/component-namespace/index.ts +0 -44
- package/layers/core/modules/component-namespace/shared.ts +0 -24
- package/layers/core/modules/kestrel/app-shell.ts +0 -55
- package/layers/core/modules/kestrel/auto-imports.ts +0 -44
- package/layers/core/modules/kestrel/index.ts +0 -144
- package/layers/core/modules/plugin-order/index.ts +0 -69
- package/layers/core/modules/plugin-order/plugin-order.ts +0 -273
- package/layers/core/nuxt.config.ts +0 -10
- package/layers/core/server/api/[...path].ts +0 -98
- package/layers/core/server/plugins/00.config.ts +0 -12
- package/layers/core/server/plugins/00.migrate.ts +0 -12
- package/layers/core/server/plugins/01.register-introspection-pipeline.ts +0 -5
- package/layers/core/server/plugins/01.register-openapi-pipeline.ts +0 -5
- package/layers/core/server/plugins/01.register-tooling-pipelines.ts +0 -5
- package/layers/core/server/plugins/02.schema-sync.ts +0 -58
- package/layers/core/server/plugins/04.outbox-worker.ts +0 -31
- package/layers/core/server/plugins/05.reindex-refs.ts +0 -5
- package/layers/core/server/routes/__kestrel/dashboard.get.ts +0 -29
- package/layers/core/server/tasks/db/migrate-module.ts +0 -83
- package/layers/core/server/tasks/db/migrate-revisions.ts +0 -20
- package/layers/core/server/tasks/db/migrate.ts +0 -50
- package/layers/core/server/utils/server-config.ts +0 -81
- package/layers/fields/nuxt.config.ts +0 -1
- package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +0 -7
- package/layers/fields/server/plugins/01.register-field-populate.ts +0 -11
- package/layers/media/app/components/KestrelImg.vue +0 -94
- package/layers/media/app/components/MediaConflictDialog.vue +0 -35
- package/layers/media/app/components/MediaDeleteDialog.vue +0 -52
- package/layers/media/app/components/MediaGrid.vue +0 -113
- package/layers/media/app/components/MediaImage.vue +0 -70
- package/layers/media/app/components/MediaLibrary.vue +0 -456
- package/layers/media/app/components/MediaNewFolderDialog.vue +0 -32
- package/layers/media/app/components/MediaPathBar.vue +0 -163
- package/layers/media/app/components/MediaPicker.vue +0 -19
- package/layers/media/app/components/MediaRenameDialog.vue +0 -32
- package/layers/media/app/components/MediaTable.vue +0 -75
- package/layers/media/app/components/MediaThumb.vue +0 -25
- package/layers/media/app/components/MediaToolbar.vue +0 -90
- package/layers/media/app/components/MediaUploadDialog.vue +0 -87
- package/layers/media/app/components/MediaViewer.vue +0 -137
- package/layers/media/app/components/field/Media.vue +0 -263
- package/layers/media/app/composables/useBlurUp.ts +0 -55
- package/layers/media/app/composables/useMediaClipboard.ts +0 -14
- package/layers/media/app/composables/useMediaContextMenu.ts +0 -56
- package/layers/media/app/composables/useMediaDnd.ts +0 -56
- package/layers/media/app/composables/useMediaLibrary.ts +0 -152
- package/layers/media/app/composables/useMediaOps.ts +0 -50
- package/layers/media/app/composables/useMediaRelocate.ts +0 -39
- package/layers/media/app/composables/useMediaResolver.ts +0 -50
- package/layers/media/app/composables/useMediaUpload.ts +0 -93
- package/layers/media/app/composables/useMediaVariant.ts +0 -34
- package/layers/media/app/plugins/field-media.client.ts +0 -6
- package/layers/media/app/utils/ai-disclosure.ts +0 -19
- package/layers/media/app/utils/dnd.ts +0 -69
- package/layers/media/app/utils/library.ts +0 -81
- package/layers/media/app/utils/ops.ts +0 -60
- package/layers/media/app/utils/picture-model.ts +0 -125
- package/layers/media/app/utils/thumbhash.ts +0 -19
- package/layers/media/app/utils/variant-name.ts +0 -20
- package/layers/media/nuxt.config.ts +0 -7
- package/layers/media/server/middleware/ondemand-variants.ts +0 -38
- package/layers/media/server/plugins/01.register-media-pipelines.ts +0 -7
- package/layers/media/server/plugins/02.register-media.ts +0 -22
- package/layers/media/server/plugins/04.variant-capture.ts +0 -27
- package/layers/media/server/plugins/05.media-cleanup.ts +0 -5
- package/layers/media/server/tasks/media/backfill.ts +0 -17
- package/layers/public/app/app.vue +0 -5
- package/layers/public/app/components/BlockRenderer.vue +0 -56
- package/layers/public/app/components/KestrelLink.vue +0 -26
- package/layers/public/app/components/KestrelPreviewBridge.vue +0 -133
- package/layers/public/app/composables/public-page.ts +0 -24
- package/layers/public/app/error.vue +0 -12
- package/layers/public/app/layouts/default.vue +0 -5
- package/layers/public/app/pages/[...slug].vue +0 -239
- package/layers/public/app/pages/__kestrel/preview.vue +0 -46
- package/layers/public/app/utils/block-edit-context.ts +0 -16
- package/layers/public/app/utils/json-ld.ts +0 -139
- package/layers/public/app/utils/link-href.ts +0 -46
- package/layers/public/app/utils/page-head.ts +0 -84
- package/layers/public/app/utils/page-layout.ts +0 -18
- package/layers/public/app/utils/preview-protocol.ts +0 -117
- package/layers/public/app/utils/public-route.ts +0 -46
- package/layers/public/app/utils/site-head.ts +0 -43
- package/layers/public/modules/deploy-output/index.ts +0 -80
- package/layers/public/modules/prerender-routes/discover.ts +0 -37
- package/layers/public/modules/prerender-routes/index.ts +0 -88
- package/layers/public/modules/prerender-routes/routes.ts +0 -16
- package/layers/public/modules/prune-media/index.ts +0 -48
- package/layers/public/modules/prune-media/prune-media.ts +0 -81
- package/layers/public/nuxt.config.ts +0 -14
- package/layers/public/server/delivery-live/serve.ts +0 -111
- package/layers/public/server/middleware/delivery-live-catchall.ts +0 -7
- package/layers/public/server/pipelines/index.ts +0 -23
- package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +0 -14
- package/layers/public/server/plugins/01.register-public-pipelines.ts +0 -7
- package/layers/public/server/plugins/02.register-links.ts +0 -16
- package/layers/public/server/plugins/03.redirects.ts +0 -55
- package/layers/public/server/plugins/05.plan-publish.ts +0 -5
- package/layers/public/server/plugins/zz.publish.ts +0 -127
- package/layers/public/server/routes/llms-full.txt.get.ts +0 -99
- package/layers/public/server/routes/llms.txt.get.ts +0 -66
- package/layers/public/server/routes/redirects.json.get.ts +0 -58
- package/layers/public/server/routes/robots.txt.get.ts +0 -11
- package/layers/public/server/routes/sitemap.xml.get.ts +0 -79
- package/layers/public/server/tasks/publish/run.ts +0 -32
- package/layers/public/server/utils/publish/render-live.ts +0 -26
- package/layers/ui/app/assets/scss/_base.scss +0 -16
- package/layers/ui/app/assets/scss/_datepicker.scss +0 -82
- package/layers/ui/app/assets/scss/_mixins.scss +0 -50
- package/layers/ui/app/assets/scss/_reset.scss +0 -65
- package/layers/ui/app/assets/scss/_tokens.scss +0 -183
- package/layers/ui/app/assets/scss/main.scss +0 -4
- package/layers/ui/app/components/field/Boolean.vue +0 -30
- package/layers/ui/app/components/field/Choice.vue +0 -73
- package/layers/ui/app/components/field/Datetime.vue +0 -98
- package/layers/ui/app/components/field/DeadRefNote.vue +0 -28
- package/layers/ui/app/components/field/Json.vue +0 -62
- package/layers/ui/app/components/field/Layout.vue +0 -138
- package/layers/ui/app/components/field/Link.vue +0 -112
- package/layers/ui/app/components/field/LinkInternalPicker.vue +0 -95
- package/layers/ui/app/components/field/Number.vue +0 -30
- package/layers/ui/app/components/field/Relation.vue +0 -49
- package/layers/ui/app/components/field/Renderer.vue +0 -30
- package/layers/ui/app/components/field/Repeater.vue +0 -460
- package/layers/ui/app/components/field/Richtext.vue +0 -26
- package/layers/ui/app/components/field/Slug.vue +0 -65
- package/layers/ui/app/components/field/Text.vue +0 -35
- package/layers/ui/app/components/field/Unsupported.vue +0 -34
- package/layers/ui/app/components/ui/Alert.vue +0 -50
- package/layers/ui/app/components/ui/Brand.vue +0 -22
- package/layers/ui/app/components/ui/Button.vue +0 -116
- package/layers/ui/app/components/ui/ButtonGroup.vue +0 -96
- package/layers/ui/app/components/ui/Checkbox.vue +0 -53
- package/layers/ui/app/components/ui/CheckboxGroup.vue +0 -51
- package/layers/ui/app/components/ui/Combobox.vue +0 -345
- package/layers/ui/app/components/ui/DatePicker.vue +0 -92
- package/layers/ui/app/components/ui/DateRangePicker.vue +0 -100
- package/layers/ui/app/components/ui/Dialog.vue +0 -107
- package/layers/ui/app/components/ui/EmptyState.vue +0 -46
- package/layers/ui/app/components/ui/Field.vue +0 -58
- package/layers/ui/app/components/ui/Fieldset.vue +0 -65
- package/layers/ui/app/components/ui/Icon.vue +0 -44
- package/layers/ui/app/components/ui/Menu.vue +0 -54
- package/layers/ui/app/components/ui/NumberInput.vue +0 -175
- package/layers/ui/app/components/ui/Richtext.vue +0 -141
- package/layers/ui/app/components/ui/RichtextToolbar.vue +0 -251
- package/layers/ui/app/components/ui/Select.vue +0 -52
- package/layers/ui/app/components/ui/TextInput.vue +0 -111
- package/layers/ui/app/components/ui/Textarea.vue +0 -39
- package/layers/ui/app/components/ui/TimeInput.vue +0 -18
- package/layers/ui/app/components/ui/Toasts.vue +0 -103
- package/layers/ui/app/components/ui/Tooltip.vue +0 -45
- package/layers/ui/app/composables/useAdminLang.ts +0 -8
- package/layers/ui/app/composables/useDragReorder.ts +0 -41
- package/layers/ui/app/composables/useEchoGuard.ts +0 -28
- package/layers/ui/app/composables/useLinkField.ts +0 -70
- package/layers/ui/app/composables/useRecordOptions.ts +0 -84
- package/layers/ui/app/composables/useRepeater.ts +0 -99
- package/layers/ui/app/composables/useT.ts +0 -32
- package/layers/ui/app/composables/useToast.ts +0 -46
- package/layers/ui/app/i18n/de.ts +0 -432
- package/layers/ui/app/i18n/en.ts +0 -434
- package/layers/ui/app/utils/boolean-field.ts +0 -10
- package/layers/ui/app/utils/field-component.ts +0 -22
- package/layers/ui/app/utils/field-empty.ts +0 -21
- package/layers/ui/app/utils/field-registry.ts +0 -36
- package/layers/ui/app/utils/humanize.ts +0 -14
- package/layers/ui/app/utils/icons.ts +0 -143
- package/layers/ui/app/utils/iso-date.ts +0 -18
- package/layers/ui/app/utils/localized.ts +0 -13
- package/layers/ui/app/utils/record-context.ts +0 -15
- package/layers/ui/app/utils/reorder.ts +0 -8
- package/layers/ui/app/utils/richtext-preserve-class.ts +0 -31
- package/layers/ui/app/utils/useFieldA11y.ts +0 -40
- package/layers/ui/nuxt.config.ts +0 -9
- package/nuxt.config.ts +0 -108
- package/scripts/consumer-template-ci.mjs +0 -160
- package/scripts/copy-create-payload.mjs +0 -49
- package/scripts/coverage-all.mjs +0 -163
- package/scripts/dashboard.mjs +0 -89
- package/scripts/hash-password.mjs +0 -29
- package/scripts/kestrel.mjs +0 -240
- package/scripts/lib/cli.mjs +0 -129
- package/scripts/lib/coverage-model.mjs +0 -51
- package/scripts/lib/password.mjs +0 -19
- package/scripts/lib/scaffold.mjs +0 -205
- package/scripts/port-graph.mjs +0 -350
- package/scripts/typecheck.mjs +0 -62
- package/templates/starter/README.md +0 -65
- package/templates/starter/_env.example +0 -17
- package/templates/starter/_gitignore +0 -26
- package/templates/starter/_package.json +0 -22
- package/templates/starter/app/app.vue +0 -7
- package/templates/starter/app/blocks/Prose.vue +0 -13
- package/templates/starter/app/layouts/default.vue +0 -6
- package/templates/starter/nuxt.config.ts +0 -23
- package/templates/starter/pnpm-workspace.yaml +0 -8
- package/templates/starter/tsconfig.json +0 -3
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import { computed } from 'vue'
|
|
3
|
-
|
|
4
|
-
// Attrs (id, aria-*) must land on the inner <input>, not the suffix wrapper, so the field label's
|
|
5
|
-
// `for` keeps pointing at the control. With no suffix the input still receives them via v-bind="$attrs".
|
|
6
|
-
defineOptions({ inheritAttrs: false })
|
|
7
|
-
|
|
8
|
-
withDefaults(
|
|
9
|
-
defineProps<{
|
|
10
|
-
min?: number
|
|
11
|
-
max?: number
|
|
12
|
-
step?: number | 'any'
|
|
13
|
-
placeholder?: string
|
|
14
|
-
disabled?: boolean
|
|
15
|
-
// Display-only suffix (e.g. 'rem'). When set, the input gains a trailing adornment; the emitted
|
|
16
|
-
// model value is still a bare number. When absent, the plain `<input class="ui-number">` renders.
|
|
17
|
-
suffix?: string
|
|
18
|
-
/** Tighter padding for dense chrome (toolbars). Still meets the AA minimum target size. */
|
|
19
|
-
slim?: boolean
|
|
20
|
-
}>(),
|
|
21
|
-
{ disabled: false, slim: false },
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
const model = defineModel<number | null>()
|
|
25
|
-
|
|
26
|
-
// Bound with `v-model` rather than :value/@input on purpose: mid-edit text like '1.0' parses to the
|
|
27
|
-
// number 1, and a plain :value binding would write that back over the text, deleting the trailing digit
|
|
28
|
-
// and moving the caret ('1.05' would end up as 15). v-model's directive skips the DOM write while the
|
|
29
|
-
// field is focused and its text still parses to the same number, so typing stays intact.
|
|
30
|
-
// The proxy only maps the empty field to null; v-model already hands over a number for a number input.
|
|
31
|
-
const text = computed<number | string>({
|
|
32
|
-
get: () => model.value ?? '',
|
|
33
|
-
set: (v) => { model.value = v === '' ? null : Number(v) },
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
// A number input can hold editing text the browser can't parse ('-', '1e'); its DOM value is then ''
|
|
37
|
-
// and the model already null, so a save would silently drop what the user still sees in the field.
|
|
38
|
-
// Snap the text back to the model so the field never shows a value that wouldn't be saved.
|
|
39
|
-
// (Assigning `value` clears the unparsable editing text even though the raw value is already ''.)
|
|
40
|
-
function snapBack(el: HTMLInputElement) {
|
|
41
|
-
if (el.validity?.badInput) el.value = model.value == null ? '' : String(model.value)
|
|
42
|
-
}
|
|
43
|
-
function onBlur(e: Event) {
|
|
44
|
-
snapBack(e.target as HTMLInputElement)
|
|
45
|
-
}
|
|
46
|
-
// Enter fires the ancestor form's implicit submission WITHOUT a preceding blur, so the blur guard
|
|
47
|
-
// can't run — with the form's native validation off, a null would be saved while unparsable text
|
|
48
|
-
// still shows. Suppress that submit and snap back so the mismatch is corrected and visible instead.
|
|
49
|
-
function onKeydown(e: KeyboardEvent) {
|
|
50
|
-
if (e.key !== 'Enter' || e.isComposing) return
|
|
51
|
-
const el = e.target as HTMLInputElement
|
|
52
|
-
if (el.validity?.badInput) { e.preventDefault(); snapBack(el) }
|
|
53
|
-
}
|
|
54
|
-
</script>
|
|
55
|
-
|
|
56
|
-
<template>
|
|
57
|
-
<div v-if="suffix" class="ui-number-wrap" :class="{ 'ui-number-wrap--slim': slim }" :data-disabled="disabled || undefined">
|
|
58
|
-
<!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label -- id/label land via v-bind="$attrs" from the wrapping UiField, invisible to static analysis -->
|
|
59
|
-
<input
|
|
60
|
-
v-model="text"
|
|
61
|
-
type="number"
|
|
62
|
-
:min="min"
|
|
63
|
-
:max="max"
|
|
64
|
-
:step="step"
|
|
65
|
-
:placeholder="placeholder"
|
|
66
|
-
:disabled="disabled"
|
|
67
|
-
class="ui-number-wrap__input"
|
|
68
|
-
v-bind="$attrs"
|
|
69
|
-
@blur="onBlur"
|
|
70
|
-
@keydown="onKeydown"
|
|
71
|
-
>
|
|
72
|
-
<span class="ui-number-wrap__suffix" aria-hidden="true">{{ suffix }}</span>
|
|
73
|
-
</div>
|
|
74
|
-
<!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label -- id/label land via v-bind="$attrs" from the wrapping UiField, invisible to static analysis -->
|
|
75
|
-
<input
|
|
76
|
-
v-else
|
|
77
|
-
v-model="text"
|
|
78
|
-
type="number"
|
|
79
|
-
:min="min"
|
|
80
|
-
:max="max"
|
|
81
|
-
:step="step"
|
|
82
|
-
:placeholder="placeholder"
|
|
83
|
-
:disabled="disabled"
|
|
84
|
-
class="ui-number"
|
|
85
|
-
:class="{ 'ui-number--slim': slim }"
|
|
86
|
-
v-bind="$attrs"
|
|
87
|
-
@blur="onBlur"
|
|
88
|
-
@keydown="onKeydown"
|
|
89
|
-
>
|
|
90
|
-
</template>
|
|
91
|
-
|
|
92
|
-
<style lang="scss">
|
|
93
|
-
@use '../../assets/scss/mixins';
|
|
94
|
-
|
|
95
|
-
.ui-number {
|
|
96
|
-
@include mixins.input-base;
|
|
97
|
-
width: 100%;
|
|
98
|
-
|
|
99
|
-
&::placeholder {
|
|
100
|
-
color: var(--color-text-muted);
|
|
101
|
-
}
|
|
102
|
-
&--slim {
|
|
103
|
-
@include mixins.input-slim;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Suffix variant (opt-in via the `suffix` prop). The border/radius live on the wrapper; the inner input
|
|
108
|
-
// is borderless + transparent so it reads as one control, mirroring the slug prefix adornment.
|
|
109
|
-
.ui-number-wrap {
|
|
110
|
-
display: flex;
|
|
111
|
-
align-items: stretch;
|
|
112
|
-
width: 100%;
|
|
113
|
-
min-width: 0;
|
|
114
|
-
border: 1px solid var(--color-control-border, var(--color-border));
|
|
115
|
-
border-radius: var(--radius-md);
|
|
116
|
-
background: var(--color-surface);
|
|
117
|
-
overflow: hidden; // clip the square-cornered suffix background to the wrapper's rounded border
|
|
118
|
-
|
|
119
|
-
&:focus-within {
|
|
120
|
-
outline: 2px solid var(--color-focus);
|
|
121
|
-
outline-offset: -2px;
|
|
122
|
-
}
|
|
123
|
-
&[data-disabled] {
|
|
124
|
-
opacity: 0.6;
|
|
125
|
-
cursor: not-allowed;
|
|
126
|
-
}
|
|
127
|
-
// input-base flags the invalid state on the input's own border; here the border lives on the wrapper
|
|
128
|
-
// while the inner input is borderless, so mirror it so a unit field shows the same red invalid border.
|
|
129
|
-
&:has(.ui-number-wrap__input[aria-invalid='true']) {
|
|
130
|
-
border-color: var(--color-danger);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
&__input {
|
|
134
|
-
flex: 1 1 auto;
|
|
135
|
-
min-width: 0;
|
|
136
|
-
padding: var(--space-2) var(--space-3);
|
|
137
|
-
border: 0;
|
|
138
|
-
background: transparent;
|
|
139
|
-
color: var(--color-text);
|
|
140
|
-
font-size: var(--text-base);
|
|
141
|
-
|
|
142
|
-
&:focus-visible {
|
|
143
|
-
outline: none;
|
|
144
|
-
}
|
|
145
|
-
&::placeholder {
|
|
146
|
-
color: var(--color-text-muted);
|
|
147
|
-
}
|
|
148
|
-
&:disabled {
|
|
149
|
-
cursor: not-allowed;
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
&__suffix {
|
|
154
|
-
display: inline-flex;
|
|
155
|
-
align-items: center;
|
|
156
|
-
padding-inline: var(--space-3);
|
|
157
|
-
background: var(--color-surface-2);
|
|
158
|
-
color: var(--color-text-muted);
|
|
159
|
-
border-left: 1px solid var(--color-control-border, var(--color-border));
|
|
160
|
-
font-size: var(--text-sm);
|
|
161
|
-
white-space: nowrap;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// Slim adornment variant: the wrapper keeps its border/radius, only the inner box tightens. Written as a
|
|
166
|
-
// separate rule (not nested) so `.ui-number-wrap--slim .ui-number-wrap__input` outranks the base element rule.
|
|
167
|
-
.ui-number-wrap--slim {
|
|
168
|
-
.ui-number-wrap__input {
|
|
169
|
-
@include mixins.input-slim;
|
|
170
|
-
}
|
|
171
|
-
.ui-number-wrap__suffix {
|
|
172
|
-
padding-inline: var(--space-2);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
</style>
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import { watch, watchEffect } from 'vue'
|
|
3
|
-
import { useEditor, EditorContent } from '@tiptap/vue-3'
|
|
4
|
-
import { StarterKit } from '@tiptap/starter-kit'
|
|
5
|
-
import { Highlight } from '@tiptap/extension-highlight'
|
|
6
|
-
import { Subscript } from '@tiptap/extension-subscript'
|
|
7
|
-
import { Superscript } from '@tiptap/extension-superscript'
|
|
8
|
-
import { TextAlign } from '@tiptap/extension-text-align'
|
|
9
|
-
import RichtextToolbar from './RichtextToolbar.vue'
|
|
10
|
-
import { RichtextSpanClass, RichtextBlockClass } from '../../utils/richtext-preserve-class'
|
|
11
|
-
import { RICHTEXT_LINK_SCHEME } from '@michaelthielemann/kestrel-core/client'
|
|
12
|
-
|
|
13
|
-
const props = withDefaults(
|
|
14
|
-
defineProps<{
|
|
15
|
-
disabled?: boolean
|
|
16
|
-
locale?: string
|
|
17
|
-
// a11y: the editing surface is a contenteditable with no native label; the field name + error/state
|
|
18
|
-
// are set on the ProseMirror DOM node (editor.view.dom) so AT announces a named multiline textbox.
|
|
19
|
-
ariaLabel?: string
|
|
20
|
-
describedby?: string
|
|
21
|
-
invalid?: boolean
|
|
22
|
-
required?: boolean
|
|
23
|
-
}>(),
|
|
24
|
-
{ disabled: false },
|
|
25
|
-
)
|
|
26
|
-
const model = defineModel<string | null>()
|
|
27
|
-
|
|
28
|
-
// TipTap emits `update` for non-content changes too — `setEditable`/`setOptions` emit directly, past the
|
|
29
|
-
// transaction pipeline and its docChanged guard — and that echo reaches the edit form as a user edit.
|
|
30
|
-
// Only a changed doc is one, so every emit is checked against the doc it last saw.
|
|
31
|
-
let lastDoc: unknown = null
|
|
32
|
-
|
|
33
|
-
// StarterKit (v3) already bundles Link + Underline; only the rest are added here.
|
|
34
|
-
const editor = useEditor({
|
|
35
|
-
content: model.value ?? '',
|
|
36
|
-
editable: !props.disabled,
|
|
37
|
-
// (no `immediatelyRender` — not in tiptap v3's EditorOptions type; the admin editor is SPA-only so the
|
|
38
|
-
// browser default applies.)
|
|
39
|
-
extensions: [
|
|
40
|
-
StarterKit.configure({
|
|
41
|
-
heading: { levels: [1, 2, 3, 4, 5, 6] },
|
|
42
|
-
// `protocols`: the Link mark validates every href against its own scheme allowlist, which has no
|
|
43
|
-
// `kestrel:`. Without this it drops the whole anchor when stored content is loaded and makes the
|
|
44
|
-
// toolbar's `setLink` a silent no-op — opening and saving a record would destroy its internal links.
|
|
45
|
-
// `target`/`rel` null: the sanitizer owns that policy — it decorates absolute http(s) links and
|
|
46
|
-
// strips the attributes from every other kind. The mark's own defaults would stamp them on ANY
|
|
47
|
-
// anchor, so a stored internal/relative/mailto link came back changed and read as an unsaved edit.
|
|
48
|
-
link: { openOnClick: false, protocols: [RICHTEXT_LINK_SCHEME], HTMLAttributes: { target: null, rel: null } },
|
|
49
|
-
}),
|
|
50
|
-
Highlight,
|
|
51
|
-
Subscript,
|
|
52
|
-
Superscript,
|
|
53
|
-
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
|
54
|
-
RichtextSpanClass, RichtextBlockClass, // preserve consumer presentational classes on round-trip
|
|
55
|
-
],
|
|
56
|
-
onCreate: ({ editor }) => {
|
|
57
|
-
lastDoc = editor.state.doc
|
|
58
|
-
},
|
|
59
|
-
onUpdate: ({ editor }) => {
|
|
60
|
-
if (editor.state.doc === lastDoc) return
|
|
61
|
-
lastDoc = editor.state.doc
|
|
62
|
-
model.value = editor.isEmpty ? '' : editor.getHTML()
|
|
63
|
-
},
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
watch(model, (value) => {
|
|
67
|
-
const e = editor.value
|
|
68
|
-
if (!e) return
|
|
69
|
-
const next = value ?? ''
|
|
70
|
-
const current = e.isEmpty ? '' : e.getHTML()
|
|
71
|
-
// `emitUpdate: false`: this value came FROM the model, so re-emitting it would reach the edit form as
|
|
72
|
-
// a user edit — clearing the redo stack and turning a restored null back into ''.
|
|
73
|
-
if (next !== current) {
|
|
74
|
-
e.commands.setContent(next, { emitUpdate: false })
|
|
75
|
-
lastDoc = e.state.doc
|
|
76
|
-
}
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
// `false` — the second arg suppresses the update event; `disabled` tracks the form's `saving` flag, so
|
|
80
|
-
// the post-save toggle would otherwise re-dirty the form that was just rebaselined.
|
|
81
|
-
watch(() => props.disabled, (d) => editor.value?.setEditable(!d, false))
|
|
82
|
-
|
|
83
|
-
// Apply the accessible name + textbox semantics directly to the ProseMirror surface, reactively (the
|
|
84
|
-
// error-driven describedby/invalid arrive after mount). `editor.view.dom` is the contenteditable element.
|
|
85
|
-
function setAttr(el: HTMLElement, name: string, value?: string) {
|
|
86
|
-
if (value) el.setAttribute(name, value)
|
|
87
|
-
else el.removeAttribute(name)
|
|
88
|
-
}
|
|
89
|
-
watchEffect(() => {
|
|
90
|
-
const dom = editor.value?.view?.dom as HTMLElement | undefined
|
|
91
|
-
if (!dom) return
|
|
92
|
-
dom.setAttribute('role', 'textbox')
|
|
93
|
-
dom.setAttribute('aria-multiline', 'true')
|
|
94
|
-
setAttr(dom, 'aria-label', props.ariaLabel)
|
|
95
|
-
setAttr(dom, 'aria-describedby', props.describedby)
|
|
96
|
-
setAttr(dom, 'aria-invalid', props.invalid ? 'true' : undefined)
|
|
97
|
-
setAttr(dom, 'aria-required', props.required ? 'true' : undefined)
|
|
98
|
-
})
|
|
99
|
-
</script>
|
|
100
|
-
|
|
101
|
-
<template>
|
|
102
|
-
<div class="ui-richtext">
|
|
103
|
-
<RichtextToolbar :editor="editor" :locale="locale" :disabled="disabled" />
|
|
104
|
-
<EditorContent :editor="editor" class="ui-richtext__content" />
|
|
105
|
-
</div>
|
|
106
|
-
</template>
|
|
107
|
-
|
|
108
|
-
<style lang="scss">
|
|
109
|
-
.ui-richtext {
|
|
110
|
-
border: 1px solid var(--color-control-border, var(--color-border));
|
|
111
|
-
border-radius: var(--radius-md);
|
|
112
|
-
background: var(--color-surface);
|
|
113
|
-
|
|
114
|
-
&__content {
|
|
115
|
-
.tiptap {
|
|
116
|
-
padding: var(--space-3);
|
|
117
|
-
min-height: 8rem;
|
|
118
|
-
outline: none;
|
|
119
|
-
|
|
120
|
-
:where(p, ul, ol, blockquote, pre, h1, h2, h3, h4, h5, h6) { margin: 0 0 var(--space-3); }
|
|
121
|
-
:where(p, ul, ol, blockquote, pre, h1, h2, h3, h4, h5, h6):last-child { margin-bottom: 0; }
|
|
122
|
-
ul, ol { padding-inline-start: var(--space-5); }
|
|
123
|
-
blockquote {
|
|
124
|
-
border-inline-start: 3px solid var(--color-border);
|
|
125
|
-
padding-inline-start: var(--space-3);
|
|
126
|
-
color: var(--color-text-muted);
|
|
127
|
-
}
|
|
128
|
-
pre {
|
|
129
|
-
background: var(--color-bg);
|
|
130
|
-
padding: var(--space-3);
|
|
131
|
-
border-radius: var(--radius-sm);
|
|
132
|
-
overflow-x: auto;
|
|
133
|
-
}
|
|
134
|
-
code { font-family: monospace; }
|
|
135
|
-
a { color: var(--color-primary); }
|
|
136
|
-
mark { background: var(--color-highlight); color: var(--color-on-highlight); }
|
|
137
|
-
hr { border: 0; border-top: 1px solid var(--color-border); margin: var(--space-4) 0; }
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
</style>
|
|
@@ -1,251 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import { ref, computed, watch } from 'vue'
|
|
3
|
-
import type { Editor } from '@tiptap/vue-3'
|
|
4
|
-
import UiButton from './Button.vue'
|
|
5
|
-
import UiIcon from './Icon.vue'
|
|
6
|
-
import UiSelect from './Select.vue'
|
|
7
|
-
import UiTextInput from './TextInput.vue'
|
|
8
|
-
import LinkInternalPicker from '../field/LinkInternalPicker.vue'
|
|
9
|
-
import type { IconName } from '../../utils/icons'
|
|
10
|
-
import { richtextLinkHref, parseRichtextLinkHref } from '@michaelthielemann/kestrel-core/client'
|
|
11
|
-
|
|
12
|
-
const props = defineProps<{ editor: Editor | undefined; locale?: string; disabled?: boolean }>()
|
|
13
|
-
|
|
14
|
-
interface Item {
|
|
15
|
-
name: string
|
|
16
|
-
label: string
|
|
17
|
-
icon: IconName
|
|
18
|
-
run: (e: Editor) => void
|
|
19
|
-
active?: (e: Editor) => boolean
|
|
20
|
-
disabled?: (e: Editor) => boolean
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const { t } = useT()
|
|
24
|
-
|
|
25
|
-
const MARKS = computed((): Item[] => [
|
|
26
|
-
{ name: 'bold', label: t('richtext.bold'), icon: 'bold', run: (e) => e.chain().focus().toggleBold().run(), active: (e) => e.isActive('bold') },
|
|
27
|
-
{ name: 'italic', label: t('richtext.italic'), icon: 'italic', run: (e) => e.chain().focus().toggleItalic().run(), active: (e) => e.isActive('italic') },
|
|
28
|
-
{ name: 'underline', label: t('richtext.underline'), icon: 'underline', run: (e) => e.chain().focus().toggleUnderline().run(), active: (e) => e.isActive('underline') },
|
|
29
|
-
{ name: 'strike', label: t('richtext.strikethrough'), icon: 'strikethrough', run: (e) => e.chain().focus().toggleStrike().run(), active: (e) => e.isActive('strike') },
|
|
30
|
-
{ name: 'code', label: t('richtext.inlineCode'), icon: 'code', run: (e) => e.chain().focus().toggleCode().run(), active: (e) => e.isActive('code') },
|
|
31
|
-
{ name: 'highlight', label: t('richtext.highlight'), icon: 'highlighter', run: (e) => e.chain().focus().toggleHighlight().run(), active: (e) => e.isActive('highlight') },
|
|
32
|
-
{ name: 'subscript', label: t('richtext.subscript'), icon: 'subscript', run: (e) => e.chain().focus().toggleSubscript().run(), active: (e) => e.isActive('subscript') },
|
|
33
|
-
{ name: 'superscript', label: t('richtext.superscript'), icon: 'superscript', run: (e) => e.chain().focus().toggleSuperscript().run(), active: (e) => e.isActive('superscript') },
|
|
34
|
-
])
|
|
35
|
-
const BLOCKS = computed((): Item[] => [
|
|
36
|
-
{ name: 'bulletList', label: t('richtext.bulletList'), icon: 'list', run: (e) => e.chain().focus().toggleBulletList().run(), active: (e) => e.isActive('bulletList') },
|
|
37
|
-
{ name: 'orderedList', label: t('richtext.orderedList'), icon: 'list-ordered', run: (e) => e.chain().focus().toggleOrderedList().run(), active: (e) => e.isActive('orderedList') },
|
|
38
|
-
{ name: 'blockquote', label: t('richtext.blockquote'), icon: 'quote', run: (e) => e.chain().focus().toggleBlockquote().run(), active: (e) => e.isActive('blockquote') },
|
|
39
|
-
{ name: 'codeBlock', label: t('richtext.codeBlock'), icon: 'square-code', run: (e) => e.chain().focus().toggleCodeBlock().run(), active: (e) => e.isActive('codeBlock') },
|
|
40
|
-
{ name: 'horizontalRule', label: t('richtext.horizontalRule'), icon: 'minus', run: (e) => e.chain().focus().setHorizontalRule().run() },
|
|
41
|
-
])
|
|
42
|
-
const ALIGN = computed((): Item[] => [
|
|
43
|
-
{ name: 'left', label: t('richtext.alignLeft'), icon: 'align-left', run: (e) => e.chain().focus().setTextAlign('left').run(), active: (e) => e.isActive({ textAlign: 'left' }) },
|
|
44
|
-
{ name: 'center', label: t('richtext.alignCenter'), icon: 'align-center', run: (e) => e.chain().focus().setTextAlign('center').run(), active: (e) => e.isActive({ textAlign: 'center' }) },
|
|
45
|
-
{ name: 'right', label: t('richtext.alignRight'), icon: 'align-right', run: (e) => e.chain().focus().setTextAlign('right').run(), active: (e) => e.isActive({ textAlign: 'right' }) },
|
|
46
|
-
{ name: 'justify', label: t('richtext.justify'), icon: 'align-justify', run: (e) => e.chain().focus().setTextAlign('justify').run(), active: (e) => e.isActive({ textAlign: 'justify' }) },
|
|
47
|
-
])
|
|
48
|
-
const EXTRA = computed((): Item[] => [
|
|
49
|
-
{ name: 'clear', label: t('richtext.clearFormatting'), icon: 'eraser', run: (e) => e.chain().focus().unsetAllMarks().clearNodes().run() },
|
|
50
|
-
{ name: 'undo', label: t('richtext.undo'), icon: 'undo', run: (e) => e.chain().focus().undo().run(), disabled: (e) => !e.can().undo() },
|
|
51
|
-
{ name: 'redo', label: t('richtext.redo'), icon: 'redo', run: (e) => e.chain().focus().redo().run(), disabled: (e) => !e.can().redo() },
|
|
52
|
-
])
|
|
53
|
-
|
|
54
|
-
const BLOCK_OPTIONS = computed(() => [
|
|
55
|
-
{ label: t('richtext.paragraph'), value: 'p' },
|
|
56
|
-
{ label: t('richtext.heading1'), value: 'h1' }, { label: t('richtext.heading2'), value: 'h2' }, { label: t('richtext.heading3'), value: 'h3' },
|
|
57
|
-
{ label: t('richtext.heading4'), value: 'h4' }, { label: t('richtext.heading5'), value: 'h5' }, { label: t('richtext.heading6'), value: 'h6' },
|
|
58
|
-
])
|
|
59
|
-
|
|
60
|
-
// Re-render the toolbar on every editor transaction so active/disabled states stay live.
|
|
61
|
-
const tick = ref(0)
|
|
62
|
-
watch(() => props.editor, (editor, _old, onCleanup) => {
|
|
63
|
-
if (!editor) return
|
|
64
|
-
const bump = () => { tick.value++ }
|
|
65
|
-
editor.on('transaction', bump)
|
|
66
|
-
onCleanup(() => { editor.off('transaction', bump) })
|
|
67
|
-
}, { immediate: true })
|
|
68
|
-
|
|
69
|
-
const blockValue = computed(() => {
|
|
70
|
-
void tick.value
|
|
71
|
-
const e = props.editor
|
|
72
|
-
if (!e) return 'p'
|
|
73
|
-
for (let l = 1; l <= 6; l++) if (e.isActive('heading', { level: l })) return `h${l}`
|
|
74
|
-
return 'p'
|
|
75
|
-
})
|
|
76
|
-
function onBlock(v: string | null | undefined) {
|
|
77
|
-
const e = props.editor
|
|
78
|
-
if (!e || !v) return
|
|
79
|
-
if (v === 'p') e.chain().focus().setParagraph().run()
|
|
80
|
-
else e.chain().focus().toggleHeading({ level: Number(v[1]) as 1 | 2 | 3 | 4 | 5 | 6 }).run()
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const linkOpen = ref(false)
|
|
84
|
-
const linkUrl = ref('')
|
|
85
|
-
function toggleLink() {
|
|
86
|
-
const e = props.editor!
|
|
87
|
-
if (e.isActive('link')) { e.chain().focus().unsetLink().run(); return }
|
|
88
|
-
linkInternalOpen.value = false
|
|
89
|
-
linkUrl.value = (e.getAttributes('link').href as string) ?? ''
|
|
90
|
-
linkOpen.value = true
|
|
91
|
-
}
|
|
92
|
-
function applyLink() {
|
|
93
|
-
const e = props.editor!
|
|
94
|
-
const href = linkUrl.value.trim()
|
|
95
|
-
if (href) e.chain().focus().extendMarkRange('link').setLink({ href }).run()
|
|
96
|
-
linkOpen.value = false
|
|
97
|
-
linkUrl.value = ''
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Internal links: pick a record, store it as a `kestrel:<collection>:<id>` marker on a normal link
|
|
101
|
-
// mark (resolved to the real localized path at read time / in the preview).
|
|
102
|
-
const linkInternalOpen = ref(false)
|
|
103
|
-
const pickCollection = ref<string | null>(null)
|
|
104
|
-
const pickRecordId = ref<number | null>(null)
|
|
105
|
-
function toggleInternalLink() {
|
|
106
|
-
linkOpen.value = false
|
|
107
|
-
const opening = !linkInternalOpen.value
|
|
108
|
-
linkInternalOpen.value = opening
|
|
109
|
-
if (!opening) return
|
|
110
|
-
// Prefill from the link under the cursor: an existing internal marker re-selects its record (the
|
|
111
|
-
// picker loads + highlights it); an external/absent link opens the picker blank.
|
|
112
|
-
const parsed = parseRichtextLinkHref(props.editor!.getAttributes('link').href as string | undefined)
|
|
113
|
-
pickCollection.value = parsed?.collection ?? null
|
|
114
|
-
pickRecordId.value = parsed?.id ?? null
|
|
115
|
-
}
|
|
116
|
-
function applyInternalLink() {
|
|
117
|
-
const e = props.editor!
|
|
118
|
-
if (pickCollection.value && pickRecordId.value != null) {
|
|
119
|
-
e.chain().focus().extendMarkRange('link').setLink({ href: richtextLinkHref(pickCollection.value, pickRecordId.value) }).run()
|
|
120
|
-
}
|
|
121
|
-
linkInternalOpen.value = false
|
|
122
|
-
pickCollection.value = null
|
|
123
|
-
pickRecordId.value = null
|
|
124
|
-
}
|
|
125
|
-
</script>
|
|
126
|
-
|
|
127
|
-
<template>
|
|
128
|
-
<div v-if="editor" class="ui-rt-toolbar" :data-rev="tick">
|
|
129
|
-
<UiSelect
|
|
130
|
-
class="ui-rt-toolbar__block"
|
|
131
|
-
:model-value="blockValue"
|
|
132
|
-
:options="BLOCK_OPTIONS"
|
|
133
|
-
:aria-label="t('richtext.blockType')"
|
|
134
|
-
:disabled="disabled"
|
|
135
|
-
@update:model-value="onBlock"
|
|
136
|
-
/>
|
|
137
|
-
|
|
138
|
-
<div class="ui-rt-toolbar__group">
|
|
139
|
-
<UiButton
|
|
140
|
-
v-for="item in MARKS" :key="item.name"
|
|
141
|
-
type="button" size="sm" variant="ghost"
|
|
142
|
-
:class="{ 'rt-on': item.active!(editor) }"
|
|
143
|
-
:aria-label="item.label"
|
|
144
|
-
:aria-pressed="item.active!(editor) ? 'true' : 'false'"
|
|
145
|
-
:disabled="disabled"
|
|
146
|
-
@click="item.run(editor)"
|
|
147
|
-
><UiIcon :name="item.icon" :size="16" /></UiButton>
|
|
148
|
-
</div>
|
|
149
|
-
|
|
150
|
-
<div class="ui-rt-toolbar__group">
|
|
151
|
-
<UiButton
|
|
152
|
-
type="button" size="sm" variant="ghost"
|
|
153
|
-
:class="{ 'rt-on': editor.isActive('link') }"
|
|
154
|
-
:aria-label="t('richtext.link')"
|
|
155
|
-
:aria-pressed="editor.isActive('link') ? 'true' : 'false'"
|
|
156
|
-
:disabled="disabled"
|
|
157
|
-
@click="toggleLink"
|
|
158
|
-
><UiIcon name="link" :size="16" /></UiButton>
|
|
159
|
-
<UiButton
|
|
160
|
-
type="button" size="sm" variant="ghost"
|
|
161
|
-
:class="{ 'rt-on': linkInternalOpen }"
|
|
162
|
-
:aria-label="t('richtext.linkInternal')"
|
|
163
|
-
:aria-pressed="linkInternalOpen ? 'true' : 'false'"
|
|
164
|
-
:disabled="disabled"
|
|
165
|
-
@click="toggleInternalLink"
|
|
166
|
-
><UiIcon name="link-2" :size="16" /></UiButton>
|
|
167
|
-
</div>
|
|
168
|
-
|
|
169
|
-
<div class="ui-rt-toolbar__group">
|
|
170
|
-
<UiButton
|
|
171
|
-
v-for="item in BLOCKS" :key="item.name"
|
|
172
|
-
type="button" size="sm" variant="ghost"
|
|
173
|
-
:class="{ 'rt-on': item.active && item.active(editor) }"
|
|
174
|
-
:aria-label="item.label"
|
|
175
|
-
:aria-pressed="item.active ? (item.active(editor) ? 'true' : 'false') : undefined"
|
|
176
|
-
:disabled="disabled"
|
|
177
|
-
@click="item.run(editor)"
|
|
178
|
-
><UiIcon :name="item.icon" :size="16" /></UiButton>
|
|
179
|
-
</div>
|
|
180
|
-
|
|
181
|
-
<div class="ui-rt-toolbar__group">
|
|
182
|
-
<UiButton
|
|
183
|
-
v-for="item in ALIGN" :key="item.name"
|
|
184
|
-
type="button" size="sm" variant="ghost"
|
|
185
|
-
:class="{ 'rt-on': item.active!(editor) }"
|
|
186
|
-
:aria-label="item.label"
|
|
187
|
-
:aria-pressed="item.active!(editor) ? 'true' : 'false'"
|
|
188
|
-
:disabled="disabled"
|
|
189
|
-
@click="item.run(editor)"
|
|
190
|
-
><UiIcon :name="item.icon" :size="16" /></UiButton>
|
|
191
|
-
</div>
|
|
192
|
-
|
|
193
|
-
<div class="ui-rt-toolbar__group">
|
|
194
|
-
<UiButton
|
|
195
|
-
v-for="item in EXTRA" :key="item.name"
|
|
196
|
-
type="button" size="sm" variant="ghost"
|
|
197
|
-
:aria-label="item.label"
|
|
198
|
-
:disabled="disabled || (item.disabled ? item.disabled(editor) : false)"
|
|
199
|
-
@click="item.run(editor)"
|
|
200
|
-
><UiIcon :name="item.icon" :size="16" /></UiButton>
|
|
201
|
-
</div>
|
|
202
|
-
|
|
203
|
-
<div v-if="linkOpen" class="ui-rt-toolbar__link">
|
|
204
|
-
<UiTextInput v-model="linkUrl" type="url" :placeholder="t('richtext.urlPlaceholder')" :aria-label="t('richtext.linkUrl')" @keydown.enter.prevent="applyLink" />
|
|
205
|
-
<UiButton type="button" size="sm" variant="primary" @click="applyLink">{{ t('richtext.apply') }}</UiButton>
|
|
206
|
-
</div>
|
|
207
|
-
|
|
208
|
-
<div v-if="linkInternalOpen" class="ui-rt-toolbar__link">
|
|
209
|
-
<LinkInternalPicker v-model:collection="pickCollection" v-model:record-id="pickRecordId" :locale="locale ?? 'en'" />
|
|
210
|
-
<UiButton type="button" size="sm" variant="primary" :disabled="pickRecordId == null" @click="applyInternalLink">{{ t('richtext.apply') }}</UiButton>
|
|
211
|
-
</div>
|
|
212
|
-
</div>
|
|
213
|
-
</template>
|
|
214
|
-
|
|
215
|
-
<style lang="scss">
|
|
216
|
-
.ui-rt-toolbar {
|
|
217
|
-
display: flex;
|
|
218
|
-
flex-wrap: wrap;
|
|
219
|
-
align-items: center;
|
|
220
|
-
gap: var(--space-2);
|
|
221
|
-
padding: var(--space-2);
|
|
222
|
-
border-bottom: 1px solid var(--color-border);
|
|
223
|
-
|
|
224
|
-
&__group {
|
|
225
|
-
display: inline-flex;
|
|
226
|
-
gap: var(--space-1);
|
|
227
|
-
}
|
|
228
|
-
&__block {
|
|
229
|
-
width: auto;
|
|
230
|
-
min-width: 8rem;
|
|
231
|
-
}
|
|
232
|
-
&__link {
|
|
233
|
-
display: flex;
|
|
234
|
-
gap: var(--space-2);
|
|
235
|
-
flex-basis: 100%;
|
|
236
|
-
align-items: center;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
&__group .ui-button.rt-on {
|
|
240
|
-
background: var(--color-active, var(--color-surface-2));
|
|
241
|
-
color: var(--color-text);
|
|
242
|
-
|
|
243
|
-
&:hover:not(:disabled) {
|
|
244
|
-
background: var(--color-active, var(--color-surface-2));
|
|
245
|
-
}
|
|
246
|
-
.ui-icon {
|
|
247
|
-
color: var(--color-primary);
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
</style>
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import UiIcon from './Icon.vue'
|
|
3
|
-
|
|
4
|
-
defineOptions({ inheritAttrs: false })
|
|
5
|
-
|
|
6
|
-
withDefaults(
|
|
7
|
-
defineProps<{
|
|
8
|
-
options: { label: string; value: string }[]
|
|
9
|
-
placeholder?: string
|
|
10
|
-
disabled?: boolean
|
|
11
|
-
}>(),
|
|
12
|
-
{ disabled: false },
|
|
13
|
-
)
|
|
14
|
-
|
|
15
|
-
const model = defineModel<string | null>()
|
|
16
|
-
</script>
|
|
17
|
-
|
|
18
|
-
<template>
|
|
19
|
-
<span class="ui-select">
|
|
20
|
-
<!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label -- id/label land via v-bind="$attrs" from the wrapping UiField, invisible to static analysis -->
|
|
21
|
-
<select v-model="model" :disabled="disabled" class="ui-select__field" v-bind="$attrs">
|
|
22
|
-
<option v-if="placeholder" value="" disabled>{{ placeholder }}</option>
|
|
23
|
-
<option v-for="o in options" :key="o.value" :value="o.value">{{ o.label }}</option>
|
|
24
|
-
</select>
|
|
25
|
-
<UiIcon name="chevron-down" :size="14" class="ui-select__icon" />
|
|
26
|
-
</span>
|
|
27
|
-
</template>
|
|
28
|
-
|
|
29
|
-
<style lang="scss">
|
|
30
|
-
@use '../../assets/scss/mixins';
|
|
31
|
-
|
|
32
|
-
.ui-select {
|
|
33
|
-
position: relative;
|
|
34
|
-
display: block;
|
|
35
|
-
width: 100%;
|
|
36
|
-
}
|
|
37
|
-
.ui-select__field {
|
|
38
|
-
@include mixins.input-base;
|
|
39
|
-
width: 100%;
|
|
40
|
-
appearance: none;
|
|
41
|
-
// leave room for the overlaid chevron so long values don't run under it
|
|
42
|
-
padding-right: calc(var(--space-3) + 1.25rem);
|
|
43
|
-
}
|
|
44
|
-
.ui-select__icon {
|
|
45
|
-
position: absolute;
|
|
46
|
-
top: 50%;
|
|
47
|
-
right: var(--space-3);
|
|
48
|
-
transform: translateY(-50%);
|
|
49
|
-
pointer-events: none; // clicks fall through to the native select
|
|
50
|
-
color: var(--color-text-muted);
|
|
51
|
-
}
|
|
52
|
-
</style>
|