create-mithaq 0.0.0-stage → 0.1.1
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/LICENSE +22 -0
- package/LICENSES/payload.txt +22 -0
- package/LICENSES/ponytail.txt +21 -0
- package/index.js +127 -0
- package/package.json +19 -4
- package/template/apps/asas-platform/components.json +25 -0
- package/template/apps/asas-platform/gitignore +3 -0
- package/template/apps/asas-platform/package.json +55 -0
- package/template/apps/asas-platform/public/favicon.svg +1 -0
- package/template/apps/asas-platform/src/api.ts +147 -0
- package/template/apps/asas-platform/src/auth-frame.tsx +150 -0
- package/template/apps/asas-platform/src/components/mithaq/cells.tsx +112 -0
- package/template/apps/asas-platform/src/components/mithaq/controls.tsx +650 -0
- package/template/apps/asas-platform/src/components/mithaq/field-shell.tsx +53 -0
- package/template/apps/asas-platform/src/components/mithaq/filter-operators.ts +108 -0
- package/template/apps/asas-platform/src/components/mithaq/form.tsx +1467 -0
- package/template/apps/asas-platform/src/components/mithaq/list-columns.tsx +108 -0
- package/template/apps/asas-platform/src/components/mithaq/list-filters.tsx +368 -0
- package/template/apps/asas-platform/src/components/mithaq/list-pagination.tsx +153 -0
- package/template/apps/asas-platform/src/components/mithaq/list-query.ts +432 -0
- package/template/apps/asas-platform/src/components/mithaq/list-selection.tsx +74 -0
- package/template/apps/asas-platform/src/components/mithaq/list.tsx +865 -0
- package/template/apps/asas-platform/src/components/mithaq/nav.tsx +871 -0
- package/template/apps/asas-platform/src/components/mithaq/nodes.tsx +1024 -0
- package/template/apps/asas-platform/src/components/mithaq/relationship-cell.tsx +127 -0
- package/template/apps/asas-platform/src/components/mithaq/relationship-control.tsx +400 -0
- package/template/apps/asas-platform/src/components/mithaq/rich-text-nodes.ts +6 -0
- package/template/apps/asas-platform/src/components/mithaq/rich-text.tsx +1083 -0
- package/template/apps/asas-platform/src/components/mithaq/upload.tsx +686 -0
- package/template/apps/asas-platform/src/components/ui/accordion.tsx +74 -0
- package/template/apps/asas-platform/src/components/ui/alert-dialog.tsx +185 -0
- package/template/apps/asas-platform/src/components/ui/avatar.tsx +106 -0
- package/template/apps/asas-platform/src/components/ui/badge.tsx +48 -0
- package/template/apps/asas-platform/src/components/ui/breadcrumb.tsx +124 -0
- package/template/apps/asas-platform/src/components/ui/button-group.tsx +87 -0
- package/template/apps/asas-platform/src/components/ui/button.tsx +55 -0
- package/template/apps/asas-platform/src/components/ui/calendar.tsx +223 -0
- package/template/apps/asas-platform/src/components/ui/card.tsx +102 -0
- package/template/apps/asas-platform/src/components/ui/chart.tsx +371 -0
- package/template/apps/asas-platform/src/components/ui/checkbox.tsx +28 -0
- package/template/apps/asas-platform/src/components/ui/collapsible.tsx +19 -0
- package/template/apps/asas-platform/src/components/ui/combobox.tsx +308 -0
- package/template/apps/asas-platform/src/components/ui/dialog.tsx +158 -0
- package/template/apps/asas-platform/src/components/ui/direction.tsx +6 -0
- package/template/apps/asas-platform/src/components/ui/directional-icon.tsx +20 -0
- package/template/apps/asas-platform/src/components/ui/drawer.tsx +216 -0
- package/template/apps/asas-platform/src/components/ui/dropdown-menu.tsx +267 -0
- package/template/apps/asas-platform/src/components/ui/field.tsx +236 -0
- package/template/apps/asas-platform/src/components/ui/input-group.tsx +155 -0
- package/template/apps/asas-platform/src/components/ui/input-otp.tsx +84 -0
- package/template/apps/asas-platform/src/components/ui/input.tsx +19 -0
- package/template/apps/asas-platform/src/components/ui/label.tsx +19 -0
- package/template/apps/asas-platform/src/components/ui/popover.tsx +90 -0
- package/template/apps/asas-platform/src/components/ui/radio-group.tsx +37 -0
- package/template/apps/asas-platform/src/components/ui/resizable.tsx +47 -0
- package/template/apps/asas-platform/src/components/ui/scroll-area.tsx +54 -0
- package/template/apps/asas-platform/src/components/ui/select.tsx +204 -0
- package/template/apps/asas-platform/src/components/ui/separator.tsx +24 -0
- package/template/apps/asas-platform/src/components/ui/sheet.tsx +139 -0
- package/template/apps/asas-platform/src/components/ui/sidebar.tsx +714 -0
- package/template/apps/asas-platform/src/components/ui/skeleton.tsx +13 -0
- package/template/apps/asas-platform/src/components/ui/slider.tsx +55 -0
- package/template/apps/asas-platform/src/components/ui/spinner.tsx +10 -0
- package/template/apps/asas-platform/src/components/ui/switch.tsx +29 -0
- package/template/apps/asas-platform/src/components/ui/table.tsx +115 -0
- package/template/apps/asas-platform/src/components/ui/tabs.tsx +79 -0
- package/template/apps/asas-platform/src/components/ui/textarea.tsx +17 -0
- package/template/apps/asas-platform/src/components/ui/toggle-group.tsx +89 -0
- package/template/apps/asas-platform/src/components/ui/toggle.tsx +44 -0
- package/template/apps/asas-platform/src/components/ui/tooltip.tsx +63 -0
- package/template/apps/asas-platform/src/copy.ts +1 -0
- package/template/apps/asas-platform/src/hooks/use-mobile.ts +19 -0
- package/template/apps/asas-platform/src/lib/utils.ts +1 -0
- package/template/apps/asas-platform/src/links.ts +38 -0
- package/template/apps/asas-platform/src/locale-switch.tsx +26 -0
- package/template/apps/asas-platform/src/redirect.ts +1 -0
- package/template/apps/asas-platform/src/request-headers.ts +11 -0
- package/template/apps/asas-platform/src/routeTree.gen.ts +181 -0
- package/template/apps/asas-platform/src/router.tsx +7 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform/files.ts +4 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform/screen.json +3609 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform/types.ts +145 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform.$.tsx +17 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform.index.tsx +10 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform.tsx +13 -0
- package/template/apps/asas-platform/src/routes/__root.tsx +95 -0
- package/template/apps/asas-platform/src/routes/api/$.ts +30 -0
- package/template/apps/asas-platform/src/routes/auth.sign-in.tsx +72 -0
- package/template/apps/asas-platform/src/routes/unauthorized.tsx +122 -0
- package/template/apps/asas-platform/src/screen-form.tsx +34 -0
- package/template/apps/asas-platform/src/screen-list.tsx +139 -0
- package/template/apps/asas-platform/src/screen-names.ts +10 -0
- package/template/apps/asas-platform/src/screens.tsx +868 -0
- package/template/apps/asas-platform/src/server.ts +20 -0
- package/template/apps/asas-platform/src/slide.ts +22 -0
- package/template/apps/asas-platform/src/start.ts +42 -0
- package/template/apps/asas-platform/src/styles.css +200 -0
- package/template/apps/asas-platform/src/theme.ts +2 -0
- package/template/apps/asas-platform/tsconfig.json +19 -0
- package/template/apps/asas-platform/vite.config.ts +26 -0
- package/template/apps/asas-tanstack/components.json +25 -0
- package/template/apps/asas-tanstack/gitignore +3 -0
- package/template/apps/asas-tanstack/package.json +56 -0
- package/template/apps/asas-tanstack/public/favicon.svg +1 -0
- package/template/apps/asas-tanstack/src/api.ts +147 -0
- package/template/apps/asas-tanstack/src/auth-frame.tsx +150 -0
- package/template/apps/asas-tanstack/src/components/mithaq/cells.tsx +112 -0
- package/template/apps/asas-tanstack/src/components/mithaq/controls.tsx +650 -0
- package/template/apps/asas-tanstack/src/components/mithaq/field-shell.tsx +53 -0
- package/template/apps/asas-tanstack/src/components/mithaq/filter-operators.ts +108 -0
- package/template/apps/asas-tanstack/src/components/mithaq/form.tsx +1467 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list-columns.tsx +108 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list-filters.tsx +368 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list-pagination.tsx +153 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list-query.ts +432 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list-selection.tsx +74 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list.tsx +865 -0
- package/template/apps/asas-tanstack/src/components/mithaq/nav.tsx +871 -0
- package/template/apps/asas-tanstack/src/components/mithaq/nodes.tsx +1024 -0
- package/template/apps/asas-tanstack/src/components/mithaq/relationship-cell.tsx +127 -0
- package/template/apps/asas-tanstack/src/components/mithaq/relationship-control.tsx +400 -0
- package/template/apps/asas-tanstack/src/components/mithaq/rich-text-nodes.ts +6 -0
- package/template/apps/asas-tanstack/src/components/mithaq/rich-text.tsx +1083 -0
- package/template/apps/asas-tanstack/src/components/mithaq/upload.tsx +686 -0
- package/template/apps/asas-tanstack/src/components/ui/accordion.tsx +10 -0
- package/template/apps/asas-tanstack/src/components/ui/alert-dialog.tsx +173 -0
- package/template/apps/asas-tanstack/src/components/ui/avatar.tsx +104 -0
- package/template/apps/asas-tanstack/src/components/ui/badge.tsx +48 -0
- package/template/apps/asas-tanstack/src/components/ui/breadcrumb.tsx +125 -0
- package/template/apps/asas-tanstack/src/components/ui/button-group.tsx +87 -0
- package/template/apps/asas-tanstack/src/components/ui/button.tsx +56 -0
- package/template/apps/asas-tanstack/src/components/ui/calendar.tsx +223 -0
- package/template/apps/asas-tanstack/src/components/ui/card.tsx +94 -0
- package/template/apps/asas-tanstack/src/components/ui/chart.tsx +369 -0
- package/template/apps/asas-tanstack/src/components/ui/checkbox.tsx +27 -0
- package/template/apps/asas-tanstack/src/components/ui/collapsible.tsx +19 -0
- package/template/apps/asas-tanstack/src/components/ui/combobox.tsx +300 -0
- package/template/apps/asas-tanstack/src/components/ui/dialog.tsx +149 -0
- package/template/apps/asas-tanstack/src/components/ui/direction.tsx +5 -0
- package/template/apps/asas-tanstack/src/components/ui/directional-icon.tsx +20 -0
- package/template/apps/asas-tanstack/src/components/ui/drawer.tsx +215 -0
- package/template/apps/asas-tanstack/src/components/ui/dropdown-menu.tsx +267 -0
- package/template/apps/asas-tanstack/src/components/ui/field.tsx +225 -0
- package/template/apps/asas-tanstack/src/components/ui/input-group.tsx +147 -0
- package/template/apps/asas-tanstack/src/components/ui/input-otp.tsx +20 -0
- package/template/apps/asas-tanstack/src/components/ui/input.tsx +20 -0
- package/template/apps/asas-tanstack/src/components/ui/label.tsx +18 -0
- package/template/apps/asas-tanstack/src/components/ui/popover.tsx +50 -0
- package/template/apps/asas-tanstack/src/components/ui/radio-group.tsx +10 -0
- package/template/apps/asas-tanstack/src/components/ui/resizable.tsx +47 -0
- package/template/apps/asas-tanstack/src/components/ui/scroll-area.tsx +52 -0
- package/template/apps/asas-tanstack/src/components/ui/select.tsx +190 -0
- package/template/apps/asas-tanstack/src/components/ui/separator.tsx +23 -0
- package/template/apps/asas-tanstack/src/components/ui/sheet.tsx +127 -0
- package/template/apps/asas-tanstack/src/components/ui/sidebar.tsx +716 -0
- package/template/apps/asas-tanstack/src/components/ui/skeleton.tsx +13 -0
- package/template/apps/asas-tanstack/src/components/ui/slider.tsx +13 -0
- package/template/apps/asas-tanstack/src/components/ui/spinner.tsx +11 -0
- package/template/apps/asas-tanstack/src/components/ui/switch.tsx +8 -0
- package/template/apps/asas-tanstack/src/components/ui/table.tsx +119 -0
- package/template/apps/asas-tanstack/src/components/ui/tabs.tsx +80 -0
- package/template/apps/asas-tanstack/src/components/ui/textarea.tsx +18 -0
- package/template/apps/asas-tanstack/src/components/ui/toggle-group.tsx +8 -0
- package/template/apps/asas-tanstack/src/components/ui/toggle.tsx +44 -0
- package/template/apps/asas-tanstack/src/components/ui/tooltip.tsx +68 -0
- package/template/apps/asas-tanstack/src/copy.ts +870 -0
- package/template/apps/asas-tanstack/src/hooks/use-mobile.ts +19 -0
- package/template/apps/asas-tanstack/src/lib/utils.ts +6 -0
- package/template/apps/asas-tanstack/src/links.ts +38 -0
- package/template/apps/asas-tanstack/src/locale-switch.tsx +26 -0
- package/template/apps/asas-tanstack/src/quiet-abort.ts +25 -0
- package/template/apps/asas-tanstack/src/redirect.ts +20 -0
- package/template/apps/asas-tanstack/src/request-headers.ts +11 -0
- package/template/apps/asas-tanstack/src/routeTree.gen.ts +382 -0
- package/template/apps/asas-tanstack/src/router.tsx +7 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/files.ts +4 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/screen.json +3883 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/types.ts +158 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.$.tsx +17 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.index.tsx +10 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.tsx +13 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/files.ts +4 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/screen.json +1800 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/types.ts +92 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal.$.tsx +17 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal.index.tsx +10 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal.tsx +13 -0
- package/template/apps/asas-tanstack/src/routes/__root.tsx +99 -0
- package/template/apps/asas-tanstack/src/routes/api/$.ts +11 -0
- package/template/apps/asas-tanstack/src/routes/auth.accept-invitation.tsx +101 -0
- package/template/apps/asas-tanstack/src/routes/auth.forgot-password.tsx +94 -0
- package/template/apps/asas-tanstack/src/routes/auth.handoff.ts +39 -0
- package/template/apps/asas-tanstack/src/routes/auth.reset-password.tsx +109 -0
- package/template/apps/asas-tanstack/src/routes/auth.sign-in.tsx +86 -0
- package/template/apps/asas-tanstack/src/routes/auth.sign-up.tsx +122 -0
- package/template/apps/asas-tanstack/src/routes/index.tsx +142 -0
- package/template/apps/asas-tanstack/src/routes/unauthorized.tsx +122 -0
- package/template/apps/asas-tanstack/src/screen-form.tsx +34 -0
- package/template/apps/asas-tanstack/src/screen-list.tsx +139 -0
- package/template/apps/asas-tanstack/src/screen-names.ts +10 -0
- package/template/apps/asas-tanstack/src/screens.tsx +868 -0
- package/template/apps/asas-tanstack/src/server.ts +16 -0
- package/template/apps/asas-tanstack/src/slide.ts +22 -0
- package/template/apps/asas-tanstack/src/start.ts +57 -0
- package/template/apps/asas-tanstack/src/styles.css +159 -0
- package/template/apps/asas-tanstack/src/theme.ts +5 -0
- package/template/apps/asas-tanstack/tsconfig.json +19 -0
- package/template/apps/asas-tanstack/vite.config.ts +28 -0
- package/template/package.json +16 -0
- package/template/templates/asas/.env.example +16 -0
- package/template/templates/asas/components.json +22 -0
- package/template/templates/asas/mithaq.hints.json +25 -0
- package/template/templates/asas/package.json +38 -0
- package/template/templates/asas/src/asas.ts +93 -0
- package/template/templates/asas/src/audit-log/audit-log.ts +187 -0
- package/template/templates/asas/src/audit-log/sink.ts +89 -0
- package/template/templates/asas/src/audit-log/ui/activity.tsx +243 -0
- package/template/templates/asas/src/courses/courses.ts +612 -0
- package/template/templates/asas/src/courses/ui/archive-button.tsx +25 -0
- package/template/templates/asas/src/courses/ui/fee-cell.tsx +19 -0
- package/template/templates/asas/src/courses/ui/no-courses-on-offer.tsx +11 -0
- package/template/templates/asas/src/courses/ui/no-courses.tsx +13 -0
- package/template/templates/asas/src/courses/ui/on-offer.ts +6 -0
- package/template/templates/asas/src/courses/ui/status-cell.tsx +31 -0
- package/template/templates/asas/src/enrollments/enrollments.ts +137 -0
- package/template/templates/asas/src/enrollments/ui/session-cell.tsx +65 -0
- package/template/templates/asas/src/kayan/auth/user/account.access.ts +8 -0
- package/template/templates/asas/src/kayan/auth/user/account.ts +31 -0
- package/template/templates/asas/src/kayan/auth/user/index.ts +18 -0
- package/template/templates/asas/src/kayan/auth/user/invitation.access.ts +4 -0
- package/template/templates/asas/src/kayan/auth/user/invitation.ts +27 -0
- package/template/templates/asas/src/kayan/auth/user/member.access.ts +10 -0
- package/template/templates/asas/src/kayan/auth/user/member.screens.ts +37 -0
- package/template/templates/asas/src/kayan/auth/user/member.ts +25 -0
- package/template/templates/asas/src/kayan/auth/user/organization.access.ts +20 -0
- package/template/templates/asas/src/kayan/auth/user/organization.screens.ts +37 -0
- package/template/templates/asas/src/kayan/auth/user/organization.ts +24 -0
- package/template/templates/asas/src/kayan/auth/user/organizationRole.access.ts +8 -0
- package/template/templates/asas/src/kayan/auth/user/organizationRole.ts +25 -0
- package/template/templates/asas/src/kayan/auth/user/session.access.ts +8 -0
- package/template/templates/asas/src/kayan/auth/user/session.ts +28 -0
- package/template/templates/asas/src/kayan/auth/user/user.access.ts +29 -0
- package/template/templates/asas/src/kayan/auth/user/user.screens.ts +45 -0
- package/template/templates/asas/src/kayan/auth/user/user.ts +31 -0
- package/template/templates/asas/src/kayan/auth/user/verification.access.ts +8 -0
- package/template/templates/asas/src/kayan/auth/user/verification.ts +23 -0
- package/template/templates/asas/src/local-users/local-users.ts +143 -0
- package/template/templates/asas/src/media/media.ts +137 -0
- package/template/templates/asas/src/media/ui/preview.tsx +143 -0
- package/template/templates/asas/src/member-roles/member-roles.ts +207 -0
- package/template/templates/asas/src/messages/account.json +106 -0
- package/template/templates/asas/src/messages/auditLog.json +186 -0
- package/template/templates/asas/src/messages/courses.json +270 -0
- package/template/templates/asas/src/messages/enrollments.json +42 -0
- package/template/templates/asas/src/messages/invitation.json +66 -0
- package/template/templates/asas/src/messages/localUsers.json +42 -0
- package/template/templates/asas/src/messages/media.json +66 -0
- package/template/templates/asas/src/messages/member.json +42 -0
- package/template/templates/asas/src/messages/memberRoles.json +42 -0
- package/template/templates/asas/src/messages/mithaq.json +170 -0
- package/template/templates/asas/src/messages/organization.json +50 -0
- package/template/templates/asas/src/messages/organizationRole.json +50 -0
- package/template/templates/asas/src/messages/roles.json +58 -0
- package/template/templates/asas/src/messages/session.json +82 -0
- package/template/templates/asas/src/messages/sessions.json +106 -0
- package/template/templates/asas/src/messages/settings.json +66 -0
- package/template/templates/asas/src/messages/tags.json +38 -0
- package/template/templates/asas/src/messages/user.json +90 -0
- package/template/templates/asas/src/messages/verification.json +50 -0
- package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/migration.sql +177 -0
- package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/snapshot.json +2169 -0
- package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/snapshot.json +2182 -0
- package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/migration.sql +20 -0
- package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/snapshot.json +2409 -0
- package/template/templates/asas/src/migrations/20260922082945_military_ironclad/migration.sql +15 -0
- package/template/templates/asas/src/migrations/20260922082945_military_ironclad/snapshot.json +2627 -0
- package/template/templates/asas/src/migrations/20260922112630_bitter_chat/migration.sql +5 -0
- package/template/templates/asas/src/migrations/20260922112630_bitter_chat/snapshot.json +2691 -0
- package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/migration.sql +8 -0
- package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/snapshot.json +2795 -0
- package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/migration.sql +3 -0
- package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/snapshot.json +2834 -0
- package/template/templates/asas/src/migrations/20260927212525_bent_prowler/migration.sql +9 -0
- package/template/templates/asas/src/migrations/20260927212525_bent_prowler/snapshot.json +2870 -0
- package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/snapshot.json +2881 -0
- package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/migration.sql +15 -0
- package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/snapshot.json +3073 -0
- package/template/templates/asas/src/migrations/20260929024407_grey_morlun/migration.sql +21 -0
- package/template/templates/asas/src/migrations/20260929024407_grey_morlun/snapshot.json +3098 -0
- package/template/templates/asas/src/migrations/20260929130530_bored_jackal/migration.sql +21 -0
- package/template/templates/asas/src/migrations/20260929130530_bored_jackal/snapshot.json +3124 -0
- package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/migration.sql +32 -0
- package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/snapshot.json +3324 -0
- package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/migration.sql +2 -0
- package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/snapshot.json +3350 -0
- package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/migration.sql +21 -0
- package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/snapshot.json +3635 -0
- package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/migration.sql +8 -0
- package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/snapshot.json +3635 -0
- package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/migration.sql +18 -0
- package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/snapshot.json +3635 -0
- package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/migration.sql +18 -0
- package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/snapshot.json +3635 -0
- package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/migration.sql +11 -0
- package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/snapshot.json +3667 -0
- package/template/templates/asas/src/migrations/20261012000752_organization_ownership/migration.sql +48 -0
- package/template/templates/asas/src/migrations/20261012000752_organization_ownership/snapshot.json +3891 -0
- package/template/templates/asas/src/migrations/drizzle.config.ts +11 -0
- package/template/templates/asas/src/migrations/schema.ts +29 -0
- package/template/templates/asas/src/mithaq.ts +406 -0
- package/template/templates/asas/src/pages.ts +74 -0
- package/template/templates/asas/src/platform-http.ts +96 -0
- package/template/templates/asas/src/rich-text-nodes.ts +11 -0
- package/template/templates/asas/src/roles/grants.ts +47 -0
- package/template/templates/asas/src/roles/roles.ts +184 -0
- package/template/templates/asas/src/roles/ui/role-form.tsx +226 -0
- package/template/templates/asas/src/roles/ui/roles.tsx +166 -0
- package/template/templates/asas/src/roles.ts +16 -0
- package/template/templates/asas/src/seed.ts +325 -0
- package/template/templates/asas/src/sessions/sessions.ts +155 -0
- package/template/templates/asas/src/settings/settings.ts +69 -0
- package/template/templates/asas/src/statements.ts +11 -0
- package/template/templates/asas/src/steps/expected-updated-at.ts +41 -0
- package/template/templates/asas/src/storage/defaults.ts +156 -0
- package/template/templates/asas/src/storage/index.ts +95 -0
- package/template/templates/asas/src/storage/local.ts +143 -0
- package/template/templates/asas/src/storage/r2.ts +5 -0
- package/template/templates/asas/src/storage/s3.ts +91 -0
- package/template/templates/asas/src/storage/uploadthing.ts +59 -0
- package/template/templates/asas/src/tags/tags.ts +80 -0
- package/template/templates/asas/src/tenant-access.ts +221 -0
- package/template/templates/asas/src/tenant-roles.ts +262 -0
- package/template/templates/asas/src/ui/app-shell.tsx +260 -0
- package/template/templates/asas/src/ui/kpi-card.tsx +25 -0
- package/template/templates/asas/src/ui/record-page.tsx +50 -0
- package/template/templates/asas/src/ui/rich-text-nodes.tsx +15 -0
- package/template/templates/asas/src/ui/screen-nav.tsx +28 -0
- package/template/templates/asas/src/ui/session-labels.tsx +36 -0
- package/template/templates/asas/src/ui/table-page.tsx +9 -0
- package/template/templates/asas/src/ui/top-shell.tsx +60 -0
- package/template/templates/asas/src/ui-dashboard/dashboard.tsx +294 -0
- package/template/templates/asas/src/ui-dashboard/members.tsx +630 -0
- package/template/templates/asas/src/ui-dashboard/theme-provider.tsx +10 -0
- package/template/templates/asas/src/ui-dashboard/versions.tsx +432 -0
- package/template/templates/asas/src/ui-platform/home.tsx +63 -0
- package/template/templates/asas/src/ui-platform/organization-actions.tsx +114 -0
- package/template/templates/asas/src/ui-platform/record-page.tsx +30 -0
- package/template/templates/asas/src/ui-platform/role-cells.tsx +52 -0
- package/template/templates/asas/src/ui-platform/user-actions.tsx +251 -0
- package/template/templates/asas/src/ui-portal/home.tsx +77 -0
- package/template/templates/asas/src/ui-portal/page.tsx +11 -0
- package/template/templates/auth/package.json +14 -0
- package/template/templates/auth/src/index.ts +690 -0
- package/template/templates/auth/src/platform-roles.ts +87 -0
- package/README.md +0 -3
|
@@ -0,0 +1,1083 @@
|
|
|
1
|
+
import { createContext, use, useCallback, useEffect, useMemo, useRef, useState, type ComponentType, type JSX, type KeyboardEvent as ReactKeyboardEvent, type ReactElement, type ReactNode } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import { LexicalComposer } from '@lexical/react/LexicalComposer'
|
|
4
|
+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
|
5
|
+
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
|
|
6
|
+
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
|
|
7
|
+
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
|
|
8
|
+
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'
|
|
9
|
+
import { ListPlugin } from '@lexical/react/LexicalListPlugin'
|
|
10
|
+
import { CheckListPlugin } from '@lexical/react/LexicalCheckListPlugin'
|
|
11
|
+
import { HorizontalRulePlugin } from '@lexical/react/LexicalHorizontalRulePlugin'
|
|
12
|
+
import { HorizontalRuleNode as HorizontalRuleView, INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode'
|
|
13
|
+
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
|
|
14
|
+
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
|
|
15
|
+
import { LexicalTypeaheadMenuPlugin, MenuOption, useBasicTypeaheadTriggerMatch } from '@lexical/react/LexicalTypeaheadMenuPlugin'
|
|
16
|
+
import { $isLinkNode, $toggleLink, TOGGLE_LINK_COMMAND } from '@lexical/link'
|
|
17
|
+
import { Input } from '@/components/ui/input'
|
|
18
|
+
import { $isListNode, INSERT_CHECK_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND, ListNode } from '@lexical/list'
|
|
19
|
+
import { $createHeadingNode, $createQuoteNode, $isHeadingNode, $isQuoteNode, type HeadingTagType } from '@lexical/rich-text'
|
|
20
|
+
import { $isAtNodeEnd, $setBlocksType } from '@lexical/selection'
|
|
21
|
+
import { $dfs, $findMatchingParent, $getNearestBlockElementAncestorOrThrow, $getNearestNodeOfType, mergeRegister } from '@lexical/utils'
|
|
22
|
+
import {
|
|
23
|
+
$createParagraphNode,
|
|
24
|
+
$getRoot,
|
|
25
|
+
createEditor,
|
|
26
|
+
$getSelection,
|
|
27
|
+
$isElementNode,
|
|
28
|
+
$isLineBreakNode,
|
|
29
|
+
$isNodeSelection,
|
|
30
|
+
$isRangeSelection,
|
|
31
|
+
$isTextNode,
|
|
32
|
+
BLUR_COMMAND,
|
|
33
|
+
CAN_REDO_COMMAND,
|
|
34
|
+
CAN_UNDO_COMMAND,
|
|
35
|
+
COMMAND_PRIORITY_CRITICAL,
|
|
36
|
+
COMMAND_PRIORITY_HIGH,
|
|
37
|
+
COMMAND_PRIORITY_LOW,
|
|
38
|
+
FORMAT_ELEMENT_COMMAND,
|
|
39
|
+
FORMAT_TEXT_COMMAND,
|
|
40
|
+
HISTORY_MERGE_TAG,
|
|
41
|
+
INDENT_CONTENT_COMMAND,
|
|
42
|
+
KEY_ESCAPE_COMMAND,
|
|
43
|
+
OUTDENT_CONTENT_COMMAND,
|
|
44
|
+
REDO_COMMAND,
|
|
45
|
+
SELECTION_CHANGE_COMMAND,
|
|
46
|
+
UNDO_COMMAND,
|
|
47
|
+
type BaseSelection,
|
|
48
|
+
type ElementFormatType,
|
|
49
|
+
type EditorState,
|
|
50
|
+
type ElementNode,
|
|
51
|
+
type Klass,
|
|
52
|
+
type LexicalEditor,
|
|
53
|
+
type LexicalNode,
|
|
54
|
+
type RangeSelection,
|
|
55
|
+
type TextFormatType,
|
|
56
|
+
type TextNode,
|
|
57
|
+
} from 'lexical'
|
|
58
|
+
import {
|
|
59
|
+
CheckIcon,
|
|
60
|
+
PencilIcon,
|
|
61
|
+
TrashIcon,
|
|
62
|
+
XIcon,
|
|
63
|
+
AlignCenterIcon,
|
|
64
|
+
AlignJustifyIcon,
|
|
65
|
+
AlignLeftIcon,
|
|
66
|
+
AlignRightIcon,
|
|
67
|
+
BoldIcon,
|
|
68
|
+
ChevronDownIcon,
|
|
69
|
+
CodeIcon,
|
|
70
|
+
EraserIcon,
|
|
71
|
+
Heading1Icon,
|
|
72
|
+
Heading2Icon,
|
|
73
|
+
Heading3Icon,
|
|
74
|
+
IndentDecreaseIcon,
|
|
75
|
+
IndentIncreaseIcon,
|
|
76
|
+
ItalicIcon,
|
|
77
|
+
LinkIcon,
|
|
78
|
+
ListIcon,
|
|
79
|
+
ListOrderedIcon,
|
|
80
|
+
ListTodoIcon,
|
|
81
|
+
MinusIcon,
|
|
82
|
+
QuoteIcon,
|
|
83
|
+
RedoIcon,
|
|
84
|
+
StrikethroughIcon,
|
|
85
|
+
TextIcon,
|
|
86
|
+
UnderlineIcon,
|
|
87
|
+
UndoIcon,
|
|
88
|
+
} from 'lucide-react'
|
|
89
|
+
import { useDirection } from '@/components/ui/direction'
|
|
90
|
+
import { Button } from '@/components/ui/button'
|
|
91
|
+
import { ButtonGroup } from '@/components/ui/button-group'
|
|
92
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
|
93
|
+
import { Toggle } from '@/components/ui/toggle'
|
|
94
|
+
import { cn } from '@/lib/utils'
|
|
95
|
+
import { ariaRequired, changeOf, type ControlProps, type Translate } from './controls.tsx'
|
|
96
|
+
import { richTextBounds, safeUrl, typesOf, unknownType } from 'mithaq/rich-text'
|
|
97
|
+
import { defaultNodes } from './rich-text-nodes.ts'
|
|
98
|
+
|
|
99
|
+
/** One node of Lexical's serialized editor state. */
|
|
100
|
+
export type RichTextNode = { type: string; children?: RichTextNode[]; text?: string; [key: string]: unknown }
|
|
101
|
+
/** What a `richText` field holds. */
|
|
102
|
+
export type RichTextValue = { root: RichTextNode & { type: 'root'; children: RichTextNode[] }; [key: string]: unknown }
|
|
103
|
+
export type RichTextDirection = 'ltr' | 'rtl'
|
|
104
|
+
|
|
105
|
+
/** One empty paragraph, with no direction of its own so the block takes the one its first letter gives it. */
|
|
106
|
+
const emptyValue = (): RichTextValue => ({
|
|
107
|
+
root: {
|
|
108
|
+
children: [{ children: [], direction: null, format: '', indent: 0, textFormat: 0, textStyle: '', type: 'paragraph', version: 1 }],
|
|
109
|
+
direction: null,
|
|
110
|
+
format: '',
|
|
111
|
+
indent: 0,
|
|
112
|
+
type: 'root',
|
|
113
|
+
version: 1,
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const valueOf = (value: unknown): RichTextValue => {
|
|
118
|
+
const held = value && typeof value === 'object' && (value as RichTextValue).root ? (value as RichTextValue) : undefined
|
|
119
|
+
return held?.root.children?.length ? held : emptyValue()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Whether the editor holds nothing at all: only empty paragraphs, which the control reports as `null`. */
|
|
123
|
+
const blank = (value: RichTextValue) => value.root.children.every((node) => node.type === 'paragraph' && !node.children?.length)
|
|
124
|
+
|
|
125
|
+
const theme = {
|
|
126
|
+
paragraph: 'mb-2',
|
|
127
|
+
heading: {
|
|
128
|
+
h1: 'mb-3 text-2xl font-semibold',
|
|
129
|
+
h2: 'mb-3 text-xl font-semibold',
|
|
130
|
+
h3: 'mb-2 text-lg font-semibold',
|
|
131
|
+
},
|
|
132
|
+
list: {
|
|
133
|
+
nested: { listitem: 'list-none before:hidden after:hidden' },
|
|
134
|
+
ol: 'ms-6 list-decimal',
|
|
135
|
+
ul: 'ms-6 list-disc',
|
|
136
|
+
checklist: 'ms-2 list-none',
|
|
137
|
+
listitem: 'mb-1',
|
|
138
|
+
listitemChecked:
|
|
139
|
+
'relative mb-1 list-none px-7 line-through opacity-70 before:absolute before:start-1 before:top-1 before:size-4 before:rounded before:border before:border-primary before:bg-primary after:absolute after:start-[9px] after:top-[5px] after:block after:h-2 after:w-1 after:rotate-45 after:border-b-2 after:border-e-2 after:border-white after:content-[""]',
|
|
140
|
+
listitemUnchecked:
|
|
141
|
+
'relative mb-1 list-none px-7 before:absolute before:start-1 before:top-1 before:size-4 before:rounded before:border before:border-primary before:content-[""]',
|
|
142
|
+
},
|
|
143
|
+
quote: 'mb-2 border-s-2 ps-3 text-muted-foreground',
|
|
144
|
+
link: 'text-primary underline underline-offset-4',
|
|
145
|
+
hr: 'my-3 border-t',
|
|
146
|
+
text: {
|
|
147
|
+
bold: 'font-semibold',
|
|
148
|
+
italic: 'italic',
|
|
149
|
+
underline: 'underline underline-offset-2',
|
|
150
|
+
strikethrough: 'line-through',
|
|
151
|
+
underlineStrikethrough: 'underline line-through',
|
|
152
|
+
code: 'rounded bg-muted px-1 font-mono text-[0.9em]',
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** What a toolbar or a slash entry runs with: the editor, and whether the entry is the block the selection is in. */
|
|
157
|
+
type Selected = { editor: LexicalEditor; isActive: boolean }
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* One entry of the `/` menu, and of the block menu where `block: true`: the block type it makes,
|
|
161
|
+
* its words in each locale, its icon, and what it does. A project's own node brings its own pick beside `defaultPicks`.
|
|
162
|
+
*/
|
|
163
|
+
export type RichTextPick = {
|
|
164
|
+
key: string
|
|
165
|
+
label: Readonly<Record<string, string>>
|
|
166
|
+
Icon: ComponentType<{ className?: string }>
|
|
167
|
+
keywords?: readonly string[]
|
|
168
|
+
block?: true
|
|
169
|
+
onSelect: (selected: Selected) => void
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const pickLabel = (pick: RichTextPick, locale: string) => pick.label[locale] ?? pick.label.en ?? pick.key
|
|
173
|
+
|
|
174
|
+
type Toolbar = { activeEditor: LexicalEditor; blockType: string; setBlockType: (blockType: string) => void }
|
|
175
|
+
|
|
176
|
+
/** Runs `callback` on every selection change and once on mount. */
|
|
177
|
+
function useSelectionChange(activeEditor: LexicalEditor, callback: (selection: BaseSelection) => void) {
|
|
178
|
+
const latest = useRef(callback)
|
|
179
|
+
latest.current = callback
|
|
180
|
+
useEffect(
|
|
181
|
+
() =>
|
|
182
|
+
activeEditor.registerCommand(
|
|
183
|
+
SELECTION_CHANGE_COMMAND,
|
|
184
|
+
() => {
|
|
185
|
+
const selection = $getSelection()
|
|
186
|
+
if (selection) latest.current(selection)
|
|
187
|
+
return false
|
|
188
|
+
},
|
|
189
|
+
COMMAND_PRIORITY_CRITICAL,
|
|
190
|
+
),
|
|
191
|
+
[activeEditor],
|
|
192
|
+
)
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
activeEditor.getEditorState().read(() => {
|
|
195
|
+
const selection = $getSelection()
|
|
196
|
+
if (selection) latest.current(selection)
|
|
197
|
+
})
|
|
198
|
+
}, [activeEditor])
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function getSelectedNode(selection: RangeSelection): TextNode | ElementNode {
|
|
202
|
+
const anchorNode = selection.anchor.getNode()
|
|
203
|
+
const focusNode = selection.focus.getNode()
|
|
204
|
+
if (anchorNode === focusNode) return anchorNode
|
|
205
|
+
return selection.isBackward()
|
|
206
|
+
? ($isAtNodeEnd(selection.focus) ? anchorNode : focusNode)
|
|
207
|
+
: ($isAtNodeEnd(selection.anchor) ? anchorNode : focusNode)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const toParagraph = (editor: LexicalEditor) =>
|
|
211
|
+
editor.update(() => {
|
|
212
|
+
const selection = $getSelection()
|
|
213
|
+
if ($isRangeSelection(selection)) $setBlocksType(selection, () => $createParagraphNode())
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
const toHeading = (editor: LexicalEditor, tag: HeadingTagType) =>
|
|
217
|
+
editor.update(() => {
|
|
218
|
+
const selection = $getSelection()
|
|
219
|
+
if ($isRangeSelection(selection)) $setBlocksType(selection, () => $createHeadingNode(tag))
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
const listOf = (command: typeof INSERT_UNORDERED_LIST_COMMAND) => ({ editor, isActive }: Selected) =>
|
|
223
|
+
isActive ? toParagraph(editor) : editor.dispatchCommand(command, undefined)
|
|
224
|
+
|
|
225
|
+
function HistoryButtons({ activeEditor, t }: { activeEditor: LexicalEditor; t: Translate }) {
|
|
226
|
+
const [canUndo, setCanUndo] = useState(false)
|
|
227
|
+
const [canRedo, setCanRedo] = useState(false)
|
|
228
|
+
useEffect(
|
|
229
|
+
() =>
|
|
230
|
+
mergeRegister(
|
|
231
|
+
activeEditor.registerCommand<boolean>(CAN_UNDO_COMMAND, (payload) => (setCanUndo(payload), false), COMMAND_PRIORITY_CRITICAL),
|
|
232
|
+
activeEditor.registerCommand<boolean>(CAN_REDO_COMMAND, (payload) => (setCanRedo(payload), false), COMMAND_PRIORITY_CRITICAL),
|
|
233
|
+
),
|
|
234
|
+
[activeEditor],
|
|
235
|
+
)
|
|
236
|
+
const undo = t('admin.richText.undo', 'Undo') ?? 'Undo'
|
|
237
|
+
const redo = t('admin.richText.redo', 'Redo') ?? 'Redo'
|
|
238
|
+
return (
|
|
239
|
+
<ButtonGroup>
|
|
240
|
+
<Button type="button" variant="outline" size="icon-sm" className="max-sm:size-11" title={undo} aria-label={undo} disabled={!canUndo} onClick={() => activeEditor.dispatchCommand(UNDO_COMMAND, undefined)}>
|
|
241
|
+
<UndoIcon className="size-4 rtl:-scale-x-100" />
|
|
242
|
+
</Button>
|
|
243
|
+
<Button type="button" variant="outline" size="icon-sm" className="max-sm:size-11" title={redo} aria-label={redo} disabled={!canRedo} onClick={() => activeEditor.dispatchCommand(REDO_COMMAND, undefined)}>
|
|
244
|
+
<RedoIcon className="size-4 rtl:-scale-x-100" />
|
|
245
|
+
</Button>
|
|
246
|
+
</ButtonGroup>
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const nearestBlock = (anchorNode: LexicalNode) =>
|
|
251
|
+
$isElementNode(anchorNode) && !anchorNode.isInline() ? anchorNode : $findMatchingParent(anchorNode, (node) => $isElementNode(node) && !node.isInline())
|
|
252
|
+
|
|
253
|
+
function BlockDropdown({ toolbar, t, locale, picks }: { toolbar: Toolbar; t: Translate; locale: string; picks: readonly RichTextPick[] }) {
|
|
254
|
+
const { activeEditor, blockType, setBlockType } = toolbar
|
|
255
|
+
useSelectionChange(activeEditor, (selection) => {
|
|
256
|
+
if (!$isRangeSelection(selection)) return
|
|
257
|
+
const anchorNode = selection.anchor.getNode()
|
|
258
|
+
const element = nearestBlock(anchorNode) ?? anchorNode.getTopLevelElementOrThrow()
|
|
259
|
+
if (activeEditor.getElementByKey(element.getKey()) === null) return
|
|
260
|
+
if ($isListNode(element)) {
|
|
261
|
+
const parent = $getNearestNodeOfType<ListNode>(anchorNode, ListNode)
|
|
262
|
+
setBlockType((parent ?? element).getListType())
|
|
263
|
+
} else {
|
|
264
|
+
setBlockType($isHeadingNode(element) ? element.getTag() : element.getType())
|
|
265
|
+
}
|
|
266
|
+
})
|
|
267
|
+
const blocks = picks.filter((pick) => pick.block)
|
|
268
|
+
const current = blocks.find((entry) => entry.key === blockType) ?? blocks[0]
|
|
269
|
+
if (!current) return null
|
|
270
|
+
// The accessible name contains the visible words, so a voice control can say them to press the button.
|
|
271
|
+
const name = (t('admin.richText.blockFormatCurrent', 'Block format: {block}') ?? 'Block format: {block}').replace('{block}', () => pickLabel(current, locale))
|
|
272
|
+
return (
|
|
273
|
+
<DropdownMenu>
|
|
274
|
+
<DropdownMenuTrigger render={<Button type="button" variant="outline" size="sm" className="gap-1 px-2 max-sm:h-11" title={name} aria-label={name} />}>
|
|
275
|
+
<current.Icon className="size-4" />
|
|
276
|
+
<span className="text-sm">{pickLabel(current, locale)}</span>
|
|
277
|
+
<ChevronDownIcon className="size-3" />
|
|
278
|
+
</DropdownMenuTrigger>
|
|
279
|
+
<DropdownMenuContent>
|
|
280
|
+
{blocks.map((pick) => (
|
|
281
|
+
<DropdownMenuItem key={pick.key} onClick={() => pick.onSelect({ editor: activeEditor, isActive: pick.key === blockType })}>
|
|
282
|
+
<div className="flex items-center gap-1 font-normal">
|
|
283
|
+
<pick.Icon className="size-4" />
|
|
284
|
+
{pickLabel(pick, locale)}
|
|
285
|
+
</div>
|
|
286
|
+
</DropdownMenuItem>
|
|
287
|
+
))}
|
|
288
|
+
</DropdownMenuContent>
|
|
289
|
+
</DropdownMenu>
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const formats = [
|
|
294
|
+
{ format: 'bold', Icon: BoldIcon, label: (t: Translate) => t('admin.richText.bold', 'Bold') },
|
|
295
|
+
{ format: 'italic', Icon: ItalicIcon, label: (t: Translate) => t('admin.richText.italic', 'Italic') },
|
|
296
|
+
{ format: 'underline', Icon: UnderlineIcon, label: (t: Translate) => t('admin.richText.underline', 'Underline') },
|
|
297
|
+
{ format: 'strikethrough', Icon: StrikethroughIcon, label: (t: Translate) => t('admin.richText.strikethrough', 'Strikethrough') },
|
|
298
|
+
{ format: 'code', Icon: CodeIcon, label: (t: Translate) => t('admin.richText.code', 'Code') },
|
|
299
|
+
] as const
|
|
300
|
+
function FormatButtons({ t }: { t: Translate }) {
|
|
301
|
+
const [editor] = useLexicalComposerContext()
|
|
302
|
+
const [active, setActive] = useState<readonly string[]>([])
|
|
303
|
+
useSelectionChange(editor, (selection) => {
|
|
304
|
+
if ($isRangeSelection(selection)) setActive(formats.filter(({ format }) => selection.hasFormat(format)).map(({ format }) => format))
|
|
305
|
+
})
|
|
306
|
+
return (
|
|
307
|
+
<ButtonGroup>
|
|
308
|
+
{formats.map(({ format, Icon, label }) => {
|
|
309
|
+
const name = label(t) ?? format
|
|
310
|
+
return (
|
|
311
|
+
<Toggle key={format} variant="outline" size="sm" className="max-sm:h-11 max-sm:min-w-11" title={name} aria-label={name} pressed={active.includes(format)} onPressedChange={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, format as TextFormatType)}>
|
|
312
|
+
<Icon className="size-4" />
|
|
313
|
+
</Toggle>
|
|
314
|
+
)
|
|
315
|
+
})}
|
|
316
|
+
</ButtonGroup>
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function ClearFormatting({ t }: { t: Translate }) {
|
|
321
|
+
const [editor] = useLexicalComposerContext()
|
|
322
|
+
const clear = useCallback(() => {
|
|
323
|
+
editor.update(() => {
|
|
324
|
+
const selection = $getSelection()
|
|
325
|
+
if (!$isRangeSelection(selection)) return
|
|
326
|
+
const { anchor, focus } = selection
|
|
327
|
+
const nodes = selection.getNodes()
|
|
328
|
+
const extractedNodes = selection.extract()
|
|
329
|
+
if (anchor.key === focus.key && anchor.offset === focus.offset) return
|
|
330
|
+
nodes.forEach((node, idx) => {
|
|
331
|
+
// The first and last node are split by the selection, so text outside
|
|
332
|
+
// it keeps its format.
|
|
333
|
+
if ($isTextNode(node)) {
|
|
334
|
+
let textNode = node
|
|
335
|
+
if (idx === 0 && anchor.offset !== 0) textNode = textNode.splitText(anchor.offset)[1] || textNode
|
|
336
|
+
if (idx === nodes.length - 1) textNode = textNode.splitText(focus.offset)[0] || textNode
|
|
337
|
+
const extractedTextNode = extractedNodes[0]
|
|
338
|
+
if (nodes.length === 1 && $isTextNode(extractedTextNode)) textNode = extractedTextNode
|
|
339
|
+
if (textNode.__style !== '') textNode.setStyle('')
|
|
340
|
+
if (textNode.__format !== 0) {
|
|
341
|
+
textNode.setFormat(0)
|
|
342
|
+
$getNearestBlockElementAncestorOrThrow(textNode).setFormat('')
|
|
343
|
+
}
|
|
344
|
+
} else if ($isHeadingNode(node) || $isQuoteNode(node)) {
|
|
345
|
+
node.replace($createParagraphNode(), true)
|
|
346
|
+
}
|
|
347
|
+
})
|
|
348
|
+
})
|
|
349
|
+
}, [editor])
|
|
350
|
+
const name = t('admin.richText.clearFormatting', 'Clear formatting') ?? 'Clear formatting'
|
|
351
|
+
return (
|
|
352
|
+
<Button type="button" variant="outline" size="icon-sm" className="max-sm:size-11" title={name} aria-label={name} onClick={clear}>
|
|
353
|
+
<EraserIcon className="size-4" />
|
|
354
|
+
</Button>
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function LinkButton({ t, setLinkEditMode }: { t: Translate; setLinkEditMode: (editing: boolean) => void }) {
|
|
359
|
+
const [editor] = useLexicalComposerContext()
|
|
360
|
+
const [isLink, setIsLink] = useState(false)
|
|
361
|
+
useSelectionChange(editor, (selection) => {
|
|
362
|
+
if (!$isRangeSelection(selection)) return
|
|
363
|
+
const node = getSelectedNode(selection)
|
|
364
|
+
setIsLink($isLinkNode(node.getParent()) || $isLinkNode(node))
|
|
365
|
+
})
|
|
366
|
+
const name = isLink ? (t('admin.richText.removeLink', 'Remove link') ?? 'Remove link') : (t('admin.richText.addLink', 'Add link') ?? 'Add link')
|
|
367
|
+
return (
|
|
368
|
+
<Toggle variant="outline" size="sm" className="max-sm:h-11 max-sm:min-w-11" title={name} aria-label={name} pressed={isLink} onPressedChange={() => {
|
|
369
|
+
setLinkEditMode(!isLink)
|
|
370
|
+
// `LinkPlugin`'s own check refuses the placeholder, so it is made past it; `dropPlaceholders` removes it once the link editor is no longer open on it.
|
|
371
|
+
if (isLink) editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
|
|
372
|
+
else editor.update(() => $toggleLink(newLink))
|
|
373
|
+
}}>
|
|
374
|
+
<LinkIcon className="size-4" />
|
|
375
|
+
</Toggle>
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const alignments = [
|
|
380
|
+
{ format: 'left', Icon: AlignLeftIcon, label: (t: Translate) => t('admin.richText.alignLeft', 'Align left') },
|
|
381
|
+
{ format: 'center', Icon: AlignCenterIcon, label: (t: Translate) => t('admin.richText.alignCenter', 'Align center') },
|
|
382
|
+
{ format: 'right', Icon: AlignRightIcon, label: (t: Translate) => t('admin.richText.alignRight', 'Align right') },
|
|
383
|
+
{ format: 'justify', Icon: AlignJustifyIcon, label: (t: Translate) => t('admin.richText.justify', 'Justify') },
|
|
384
|
+
] as const
|
|
385
|
+
function AlignButtons({ t }: { t: Translate }) {
|
|
386
|
+
const [editor] = useLexicalComposerContext()
|
|
387
|
+
const [current, setCurrent] = useState<ElementFormatType>('')
|
|
388
|
+
useSelectionChange(editor, (selection) => {
|
|
389
|
+
if (!$isRangeSelection(selection)) return
|
|
390
|
+
const node = getSelectedNode(selection)
|
|
391
|
+
const parent = node.getParent()
|
|
392
|
+
// A link is inline, so a link's alignment is its paragraph's.
|
|
393
|
+
const block = $isLinkNode(parent) ? $findMatchingParent(node, (one) => $isElementNode(one) && !one.isInline()) : undefined
|
|
394
|
+
setCurrent($isElementNode(block) ? block.getFormatType() : $isElementNode(node) ? node.getFormatType() : (parent?.getFormatType() ?? ''))
|
|
395
|
+
})
|
|
396
|
+
const outdent = t('admin.richText.outdent', 'Outdent') ?? 'Outdent'
|
|
397
|
+
const indent = t('admin.richText.indent', 'Indent') ?? 'Indent'
|
|
398
|
+
return (
|
|
399
|
+
<>
|
|
400
|
+
<ButtonGroup>
|
|
401
|
+
{alignments.map(({ format, Icon, label }) => {
|
|
402
|
+
const name = label(t) ?? format
|
|
403
|
+
return (
|
|
404
|
+
<Toggle key={format} variant="outline" size="sm" className="max-sm:h-11 max-sm:min-w-11" title={name} aria-label={name} pressed={current === format} onPressedChange={() => (setCurrent(format), editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, format))}>
|
|
405
|
+
<Icon className="size-4" />
|
|
406
|
+
</Toggle>
|
|
407
|
+
)
|
|
408
|
+
})}
|
|
409
|
+
</ButtonGroup>
|
|
410
|
+
<ButtonGroup>
|
|
411
|
+
<Button type="button" variant="outline" size="icon-sm" className="max-sm:size-11" title={outdent} aria-label={outdent} onClick={() => editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined)}>
|
|
412
|
+
<IndentDecreaseIcon className="size-4 rtl:-scale-x-100" />
|
|
413
|
+
</Button>
|
|
414
|
+
<Button type="button" variant="outline" size="icon-sm" className="max-sm:size-11" title={indent} aria-label={indent} onClick={() => editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined)}>
|
|
415
|
+
<IndentIncreaseIcon className="size-4 rtl:-scale-x-100" />
|
|
416
|
+
</Button>
|
|
417
|
+
</ButtonGroup>
|
|
418
|
+
</>
|
|
419
|
+
)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** The address a new link starts with: the link editor opens on it so it is typed over, and no save may hold it. */
|
|
423
|
+
const newLink = 'https://'
|
|
424
|
+
|
|
425
|
+
/** Unwraps every link still holding the placeholder (except the selected one when `keepSelected`) back into its words, so a saved value holds none. */
|
|
426
|
+
const dropPlaceholders = (editor: LexicalEditor, keepSelected: boolean) =>
|
|
427
|
+
editor.update(() => {
|
|
428
|
+
const selection = $getSelection()
|
|
429
|
+
const kept = keepSelected && $isRangeSelection(selection) ? $findMatchingParent(getSelectedNode(selection), $isLinkNode) : null
|
|
430
|
+
for (const { node } of $dfs()) {
|
|
431
|
+
if (!$isLinkNode(node) || node.getURL() !== newLink || node.is(kept)) continue
|
|
432
|
+
for (const child of node.getChildren()) node.insertBefore(child)
|
|
433
|
+
node.remove()
|
|
434
|
+
}
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
const holdsPlaceholder = (state: EditorState) => state.read(() => $dfs().some(({ node }) => $isLinkNode(node) && node.getURL() === newLink))
|
|
438
|
+
|
|
439
|
+
/** Whether the focus is in the link editor's own box, where a placeholder is being typed over. */
|
|
440
|
+
const typingLink = () => document.activeElement?.classList.contains('link-input') === true
|
|
441
|
+
|
|
442
|
+
const VERTICAL_GAP = 10
|
|
443
|
+
const HORIZONTAL_OFFSET = 5
|
|
444
|
+
|
|
445
|
+
function setFloatingElemPositionForLinkEditor(targetRect: DOMRect | null, floatingElem: HTMLElement, anchorElem: HTMLElement): void {
|
|
446
|
+
const scrollerElem = anchorElem.parentElement
|
|
447
|
+
if (targetRect === null || !scrollerElem) {
|
|
448
|
+
floatingElem.style.opacity = '0'
|
|
449
|
+
floatingElem.style.transform = 'translate(-10000px, -10000px)'
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
const floatingElemRect = floatingElem.getBoundingClientRect()
|
|
453
|
+
const anchorElementRect = anchorElem.getBoundingClientRect()
|
|
454
|
+
const editorScrollerRect = scrollerElem.getBoundingClientRect()
|
|
455
|
+
let top = targetRect.top - VERTICAL_GAP
|
|
456
|
+
let left = targetRect.left - HORIZONTAL_OFFSET
|
|
457
|
+
if (top < editorScrollerRect.top) top += floatingElemRect.height + targetRect.height + VERTICAL_GAP * 2
|
|
458
|
+
if (left + floatingElemRect.width > editorScrollerRect.right) left = editorScrollerRect.right - floatingElemRect.width - HORIZONTAL_OFFSET
|
|
459
|
+
// A right-to-left page puts the bubble's start on the right, which the
|
|
460
|
+
// physical offset above would push off the edge.
|
|
461
|
+
if (left < editorScrollerRect.left) left = editorScrollerRect.left
|
|
462
|
+
top -= anchorElementRect.top
|
|
463
|
+
left -= anchorElementRect.left
|
|
464
|
+
floatingElem.style.opacity = '1'
|
|
465
|
+
floatingElem.style.transform = `translate(${left}px, ${top}px)`
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function FloatingLinkEditor({ editor, isLink, setIsLink, anchorElem, isLinkEditMode, setIsLinkEditMode, t }: {
|
|
469
|
+
editor: LexicalEditor
|
|
470
|
+
isLink: boolean
|
|
471
|
+
setIsLink: (isLink: boolean) => void
|
|
472
|
+
anchorElem: HTMLElement
|
|
473
|
+
isLinkEditMode: boolean
|
|
474
|
+
setIsLinkEditMode: (editing: boolean) => void
|
|
475
|
+
t: Translate
|
|
476
|
+
}): JSX.Element {
|
|
477
|
+
const editorRef = useRef<HTMLDivElement | null>(null)
|
|
478
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
479
|
+
const [linkUrl, setLinkUrl] = useState('')
|
|
480
|
+
const [editedLinkUrl, setEditedLinkUrl] = useState(newLink)
|
|
481
|
+
// False until the address is edited, so the placeholder it opens on is not flagged invalid.
|
|
482
|
+
const [edited, setEdited] = useState(false)
|
|
483
|
+
const [lastSelection, setLastSelection] = useState<BaseSelection | null>(null)
|
|
484
|
+
|
|
485
|
+
const $updateLinkEditor = useCallback(() => {
|
|
486
|
+
const selection = $getSelection()
|
|
487
|
+
if ($isRangeSelection(selection)) {
|
|
488
|
+
const node = getSelectedNode(selection)
|
|
489
|
+
const linkParent = $findMatchingParent(node, $isLinkNode)
|
|
490
|
+
if (linkParent) setLinkUrl(linkParent.getURL())
|
|
491
|
+
else if ($isLinkNode(node)) setLinkUrl(node.getURL())
|
|
492
|
+
else setLinkUrl('')
|
|
493
|
+
if (isLinkEditMode) setEditedLinkUrl(linkUrl)
|
|
494
|
+
}
|
|
495
|
+
const editorElem = editorRef.current
|
|
496
|
+
const nativeSelection = window.getSelection()
|
|
497
|
+
const activeElement = document.activeElement
|
|
498
|
+
if (editorElem === null) return
|
|
499
|
+
const rootElement = editor.getRootElement()
|
|
500
|
+
if (selection !== null && nativeSelection !== null && rootElement !== null && rootElement.contains(nativeSelection.anchorNode) && editor.isEditable()) {
|
|
501
|
+
const domRect: DOMRect | undefined = nativeSelection.focusNode?.parentElement?.getBoundingClientRect()
|
|
502
|
+
if (domRect) {
|
|
503
|
+
domRect.y += 40
|
|
504
|
+
setFloatingElemPositionForLinkEditor(domRect, editorElem, anchorElem)
|
|
505
|
+
}
|
|
506
|
+
setLastSelection(selection)
|
|
507
|
+
} else if (!activeElement || !activeElement.classList.contains('link-input')) {
|
|
508
|
+
if (rootElement !== null) setFloatingElemPositionForLinkEditor(null, editorElem, anchorElem)
|
|
509
|
+
setLastSelection(null)
|
|
510
|
+
setIsLinkEditMode(false)
|
|
511
|
+
setLinkUrl('')
|
|
512
|
+
}
|
|
513
|
+
return true
|
|
514
|
+
}, [anchorElem, editor, setIsLinkEditMode, isLinkEditMode, linkUrl])
|
|
515
|
+
|
|
516
|
+
useEffect(() => {
|
|
517
|
+
const scrollerElem = anchorElem.parentElement
|
|
518
|
+
const update = () => editor.getEditorState().read(() => void $updateLinkEditor())
|
|
519
|
+
window.addEventListener('resize', update)
|
|
520
|
+
scrollerElem?.addEventListener('scroll', update)
|
|
521
|
+
return () => {
|
|
522
|
+
window.removeEventListener('resize', update)
|
|
523
|
+
scrollerElem?.removeEventListener('scroll', update)
|
|
524
|
+
}
|
|
525
|
+
}, [anchorElem.parentElement, editor, $updateLinkEditor])
|
|
526
|
+
|
|
527
|
+
useEffect(
|
|
528
|
+
() =>
|
|
529
|
+
mergeRegister(
|
|
530
|
+
editor.registerUpdateListener(({ editorState }) => editorState.read(() => void $updateLinkEditor())),
|
|
531
|
+
editor.registerCommand(SELECTION_CHANGE_COMMAND, () => ($updateLinkEditor(), true), COMMAND_PRIORITY_LOW),
|
|
532
|
+
editor.registerCommand(KEY_ESCAPE_COMMAND, () => (isLink ? (setIsLink(false), true) : false), COMMAND_PRIORITY_HIGH),
|
|
533
|
+
),
|
|
534
|
+
[editor, $updateLinkEditor, setIsLink, isLink],
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
useEffect(() => {
|
|
538
|
+
editor.getEditorState().read(() => void $updateLinkEditor())
|
|
539
|
+
}, [editor, $updateLinkEditor])
|
|
540
|
+
|
|
541
|
+
useEffect(() => {
|
|
542
|
+
if (isLinkEditMode && inputRef.current) {
|
|
543
|
+
inputRef.current.focus()
|
|
544
|
+
setIsLink(true)
|
|
545
|
+
}
|
|
546
|
+
}, [isLinkEditMode, isLink])
|
|
547
|
+
|
|
548
|
+
useEffect(() => setEdited(false), [isLinkEditMode])
|
|
549
|
+
// Every address written passes `safeUrl`, the check the server makes of a write.
|
|
550
|
+
const allowed = safeUrl(editedLinkUrl) && editedLinkUrl.trim() !== newLink
|
|
551
|
+
const handleLinkSubmission = () => {
|
|
552
|
+
if (lastSelection === null || !allowed) return
|
|
553
|
+
if (linkUrl !== '') editor.dispatchCommand(TOGGLE_LINK_COMMAND, editedLinkUrl.trim())
|
|
554
|
+
setEditedLinkUrl(newLink)
|
|
555
|
+
setIsLinkEditMode(false)
|
|
556
|
+
}
|
|
557
|
+
const cancel = () => {
|
|
558
|
+
if (linkUrl === newLink) editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
|
|
559
|
+
setIsLinkEditMode(false)
|
|
560
|
+
setIsLink(false)
|
|
561
|
+
}
|
|
562
|
+
const address = t('admin.richText.linkAddress', 'Link address') ?? 'Link address'
|
|
563
|
+
const cancelling = t('admin.richText.cancelLink', 'Cancel') ?? 'Cancel'
|
|
564
|
+
const saving = t('admin.richText.saveLink', 'Save link') ?? 'Save link'
|
|
565
|
+
const editing = t('admin.richText.editLink', 'Edit link') ?? 'Edit link'
|
|
566
|
+
const removing = t('admin.richText.removeLink', 'Remove link') ?? 'Remove link'
|
|
567
|
+
return (
|
|
568
|
+
<div ref={editorRef} data-link-editor className="absolute top-0 left-0 z-20 w-full max-w-sm rounded-md opacity-0 shadow-md">
|
|
569
|
+
{!isLink ? null : isLinkEditMode ? (
|
|
570
|
+
<div className="bg-popover flex items-center gap-2 rounded-md border p-1 ps-2">
|
|
571
|
+
<Input
|
|
572
|
+
ref={inputRef}
|
|
573
|
+
dir="ltr"
|
|
574
|
+
type="url"
|
|
575
|
+
aria-label={address}
|
|
576
|
+
aria-invalid={edited && !allowed ? true : undefined}
|
|
577
|
+
value={editedLinkUrl}
|
|
578
|
+
onChange={(event) => (setEdited(true), setEditedLinkUrl(event.target.value))}
|
|
579
|
+
onBlur={() => setTimeout(() => {
|
|
580
|
+
if (editor.getRootElement()?.contains(document.activeElement) || editorRef.current?.contains(document.activeElement)) return
|
|
581
|
+
dropPlaceholders(editor, false)
|
|
582
|
+
setIsLinkEditMode(false)
|
|
583
|
+
})}
|
|
584
|
+
onKeyDown={(event) => {
|
|
585
|
+
if (event.key === 'Enter') {
|
|
586
|
+
event.preventDefault()
|
|
587
|
+
handleLinkSubmission()
|
|
588
|
+
} else if (event.key === 'Escape') {
|
|
589
|
+
event.preventDefault()
|
|
590
|
+
cancel()
|
|
591
|
+
}
|
|
592
|
+
}}
|
|
593
|
+
className="link-input flex-grow"
|
|
594
|
+
/>
|
|
595
|
+
<Button type="button" size="icon" variant="ghost" title={cancelling} aria-label={cancelling} onClick={cancel} className="shrink-0">
|
|
596
|
+
<XIcon className="size-4" />
|
|
597
|
+
</Button>
|
|
598
|
+
<Button type="button" size="icon" disabled={!allowed} title={saving} aria-label={saving} onClick={handleLinkSubmission} className="shrink-0">
|
|
599
|
+
<CheckIcon className="size-4" />
|
|
600
|
+
</Button>
|
|
601
|
+
</div>
|
|
602
|
+
) : (
|
|
603
|
+
<div className="bg-popover flex items-center justify-between gap-2 rounded-md border p-1 ps-2">
|
|
604
|
+
<a href={safeUrl(linkUrl) ? linkUrl : undefined} target="_blank" rel="noopener noreferrer" dir="ltr" className="overflow-hidden text-sm text-ellipsis whitespace-nowrap">
|
|
605
|
+
{linkUrl}
|
|
606
|
+
</a>
|
|
607
|
+
<div className="flex">
|
|
608
|
+
<Button type="button" size="icon" variant="ghost" title={editing} aria-label={editing} onClick={() => (setEditedLinkUrl(linkUrl), setIsLinkEditMode(true))}>
|
|
609
|
+
<PencilIcon className="size-4" />
|
|
610
|
+
</Button>
|
|
611
|
+
<Button type="button" size="icon" variant="destructive" title={removing} aria-label={removing} onClick={() => editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)}>
|
|
612
|
+
<TrashIcon className="size-4" />
|
|
613
|
+
</Button>
|
|
614
|
+
</div>
|
|
615
|
+
</div>
|
|
616
|
+
)}
|
|
617
|
+
</div>
|
|
618
|
+
)
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Draws the link editor over the anchor while the selection sits in one link. */
|
|
622
|
+
function FloatingLinkEditorPlugin({ anchorElem, isLinkEditMode, setIsLinkEditMode, t }: {
|
|
623
|
+
anchorElem: HTMLDivElement | null
|
|
624
|
+
isLinkEditMode: boolean
|
|
625
|
+
setIsLinkEditMode: (editing: boolean) => void
|
|
626
|
+
t: Translate
|
|
627
|
+
}): JSX.Element | null {
|
|
628
|
+
const [editor] = useLexicalComposerContext()
|
|
629
|
+
const [activeEditor, setActiveEditor] = useState(editor)
|
|
630
|
+
const [isLink, setIsLink] = useState(false)
|
|
631
|
+
useEffect(() => {
|
|
632
|
+
const $updateToolbar = () => {
|
|
633
|
+
const selection = $getSelection()
|
|
634
|
+
if ($isRangeSelection(selection)) {
|
|
635
|
+
const focusLinkNode = $findMatchingParent(getSelectedNode(selection), $isLinkNode)
|
|
636
|
+
if (!focusLinkNode) {
|
|
637
|
+
setIsLink(false)
|
|
638
|
+
return
|
|
639
|
+
}
|
|
640
|
+
const badNode = selection.getNodes().filter((node) => !$isLineBreakNode(node)).find((node) => {
|
|
641
|
+
const linkNode = $findMatchingParent(node, $isLinkNode)
|
|
642
|
+
return !focusLinkNode.is(linkNode) || (linkNode && !linkNode.is(focusLinkNode))
|
|
643
|
+
})
|
|
644
|
+
setIsLink(!badNode)
|
|
645
|
+
} else if ($isNodeSelection(selection)) {
|
|
646
|
+
const node = selection.getNodes()[0]
|
|
647
|
+
setIsLink(!!node && ($isLinkNode(node.getParent()) || $isLinkNode(node)))
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
// A placeholder is dropped once the link editor is no longer open on it, so a click elsewhere or a save never stores one.
|
|
651
|
+
const stale = (editorState: EditorState) =>
|
|
652
|
+
editorState.read(() => {
|
|
653
|
+
const selection = $getSelection()
|
|
654
|
+
const inside = $isRangeSelection(selection) ? $findMatchingParent(getSelectedNode(selection), $isLinkNode) : null
|
|
655
|
+
return $dfs().some(({ node }) => $isLinkNode(node) && node.getURL() === newLink && !node.is(inside))
|
|
656
|
+
})
|
|
657
|
+
return mergeRegister(
|
|
658
|
+
editor.registerUpdateListener(({ editorState }) => {
|
|
659
|
+
editorState.read(() => $updateToolbar())
|
|
660
|
+
if (stale(editorState)) dropPlaceholders(editor, true)
|
|
661
|
+
}),
|
|
662
|
+
editor.registerCommand(SELECTION_CHANGE_COMMAND, (_payload, newEditor) => ($updateToolbar(), setActiveEditor(newEditor), false), COMMAND_PRIORITY_CRITICAL),
|
|
663
|
+
editor.registerCommand(BLUR_COMMAND, () => (setTimeout(() => typingLink() || dropPlaceholders(editor, false)), false), COMMAND_PRIORITY_LOW),
|
|
664
|
+
)
|
|
665
|
+
}, [editor])
|
|
666
|
+
if (!anchorElem) return null
|
|
667
|
+
return createPortal(
|
|
668
|
+
<FloatingLinkEditor editor={activeEditor} isLink={isLink} anchorElem={anchorElem} setIsLink={setIsLink} isLinkEditMode={isLinkEditMode} setIsLinkEditMode={setIsLinkEditMode} t={t} />,
|
|
669
|
+
anchorElem,
|
|
670
|
+
)
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const headingPick = (tag: HeadingTagType, Icon: RichTextPick['Icon'], level: string): RichTextPick => ({
|
|
674
|
+
key: tag, label: { en: `Heading ${level}`, ar: `عنوان ${level}` }, Icon, keywords: ['heading', 'header', tag], block: true, onSelect: ({ editor }) => toHeading(editor, tag),
|
|
675
|
+
})
|
|
676
|
+
export const defaultPicks: readonly RichTextPick[] = [
|
|
677
|
+
{ key: 'paragraph', label: { en: 'Paragraph', ar: 'فقرة' }, Icon: TextIcon, keywords: ['normal', 'paragraph', 'p', 'text'], block: true, onSelect: ({ editor }) => toParagraph(editor) },
|
|
678
|
+
headingPick('h1', Heading1Icon, '1'),
|
|
679
|
+
headingPick('h2', Heading2Icon, '2'),
|
|
680
|
+
headingPick('h3', Heading3Icon, '3'),
|
|
681
|
+
{ key: 'number', label: { en: 'Numbered list', ar: 'قائمة مرقمة' }, Icon: ListOrderedIcon, keywords: ['numbered list', 'ordered list', 'ol'], block: true, onSelect: listOf(INSERT_ORDERED_LIST_COMMAND) },
|
|
682
|
+
{ key: 'bullet', label: { en: 'Bulleted list', ar: 'قائمة نقطية' }, Icon: ListIcon, keywords: ['bulleted list', 'unordered list', 'ul'], block: true, onSelect: listOf(INSERT_UNORDERED_LIST_COMMAND) },
|
|
683
|
+
{ key: 'check', label: { en: 'Check list', ar: 'قائمة مهام' }, Icon: ListTodoIcon, keywords: ['check list', 'todo list'], block: true, onSelect: listOf(INSERT_CHECK_LIST_COMMAND) },
|
|
684
|
+
{
|
|
685
|
+
key: 'quote',
|
|
686
|
+
label: { en: 'Quote', ar: 'اقتباس' },
|
|
687
|
+
Icon: QuoteIcon,
|
|
688
|
+
keywords: ['block quote'],
|
|
689
|
+
block: true,
|
|
690
|
+
onSelect: ({ editor, isActive }) => (isActive ? undefined : editor.update(() => $setBlocksType($getSelection(), () => $createQuoteNode()))),
|
|
691
|
+
},
|
|
692
|
+
{ key: 'horizontalRule', label: { en: 'Divider', ar: 'خط فاصل' }, Icon: MinusIcon, keywords: ['horizontal rule', 'divider', 'hr'], onSelect: ({ editor }) => editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined) },
|
|
693
|
+
]
|
|
694
|
+
|
|
695
|
+
/** The React subclasses of nodes that draw React, each of the same type as the DOM-free class in the node list and extending it. */
|
|
696
|
+
export const defaultViews: readonly Klass<LexicalNode>[] = [HorizontalRuleView]
|
|
697
|
+
|
|
698
|
+
/** A node list as the browser draws it: each class swapped for its React subclass where one extends it. */
|
|
699
|
+
export const viewsOf = (nodes: readonly Klass<LexicalNode>[], views: readonly Klass<LexicalNode>[] = defaultViews) =>
|
|
700
|
+
nodes.map((node) => views.find((view) => view.prototype instanceof node) ?? node)
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* The nodes and picks every editor and viewer under it draws with, provided once by a screen from
|
|
704
|
+
* the project's own list so the editor, viewer and server read the same nodes. The registry's own where none is provided.
|
|
705
|
+
*/
|
|
706
|
+
export const RichTextContext = createContext<{
|
|
707
|
+
nodes: readonly Klass<LexicalNode>[]
|
|
708
|
+
picks: readonly RichTextPick[]
|
|
709
|
+
/** What a project's own node mounts inside every editor beside its nodes, handed the field's control props (for example the upload node's dialog). */
|
|
710
|
+
Plugin?: ComponentType<ControlProps>
|
|
711
|
+
}>({ nodes: viewsOf(defaultNodes), picks: defaultPicks })
|
|
712
|
+
|
|
713
|
+
class PickerOption extends MenuOption {
|
|
714
|
+
item: RichTextPick
|
|
715
|
+
title: string
|
|
716
|
+
constructor(item: RichTextPick, title: string) {
|
|
717
|
+
super(item.key)
|
|
718
|
+
this.item = item
|
|
719
|
+
this.title = title
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function PickerMenu({ options, selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }: {
|
|
724
|
+
options: PickerOption[]
|
|
725
|
+
selectedIndex: number | null
|
|
726
|
+
selectOptionAndCleanUp: (option: PickerOption) => void
|
|
727
|
+
setHighlightedIndex: (index: number) => void
|
|
728
|
+
}) {
|
|
729
|
+
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
|
|
730
|
+
useEffect(() => {
|
|
731
|
+
if (selectedIndex !== null) itemRefs.current[selectedIndex]?.scrollIntoView({ block: 'nearest', behavior: 'auto' })
|
|
732
|
+
}, [selectedIndex])
|
|
733
|
+
return (
|
|
734
|
+
<div className="border-border bg-popover text-popover-foreground h-min min-w-48 overflow-hidden rounded-lg border p-1 shadow-xl" role="listbox">
|
|
735
|
+
{options.map((option, index) => {
|
|
736
|
+
const { Icon } = option.item
|
|
737
|
+
return (
|
|
738
|
+
<button
|
|
739
|
+
key={option.key}
|
|
740
|
+
ref={(element) => {
|
|
741
|
+
itemRefs.current[index] = element
|
|
742
|
+
}}
|
|
743
|
+
type="button"
|
|
744
|
+
role="option"
|
|
745
|
+
aria-selected={selectedIndex === index}
|
|
746
|
+
className={cn('flex w-full items-center gap-2 rounded-md px-3 py-2 text-start text-sm outline-none', selectedIndex === index ? 'bg-accent' : 'hover:bg-muted')}
|
|
747
|
+
onClick={() => selectOptionAndCleanUp(option)}
|
|
748
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
749
|
+
onMouseEnter={() => setHighlightedIndex(index)}
|
|
750
|
+
>
|
|
751
|
+
<span className="text-muted-foreground flex size-5 items-center justify-center"><Icon className="size-4" /></span>
|
|
752
|
+
<span className="flex-1">{option.title}</span>
|
|
753
|
+
</button>
|
|
754
|
+
)
|
|
755
|
+
})}
|
|
756
|
+
</div>
|
|
757
|
+
)
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function SlashMenu({ locale, picks }: { locale: string; picks: readonly RichTextPick[] }): JSX.Element {
|
|
761
|
+
const [editor] = useLexicalComposerContext()
|
|
762
|
+
const [queryString, setQueryString] = useState<string | null>(null)
|
|
763
|
+
const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', { minLength: 0 })
|
|
764
|
+
const options = useMemo(() => {
|
|
765
|
+
const all = picks.map((item) => new PickerOption(item, pickLabel(item, locale)))
|
|
766
|
+
if (!queryString) return all
|
|
767
|
+
// The typed word is compared as text, never as a pattern: a lone bracket would throw in a RegExp.
|
|
768
|
+
const word = queryString.toLowerCase()
|
|
769
|
+
return all.filter((option) => option.title.toLowerCase().includes(word) || (option.item.keywords ?? []).some((keyword) => keyword.includes(word)))
|
|
770
|
+
}, [queryString, locale, picks])
|
|
771
|
+
const onSelectOption = useCallback(
|
|
772
|
+
(selected: PickerOption, nodeToRemove: TextNode | null, closeMenu: () => void) => {
|
|
773
|
+
editor.update(() => {
|
|
774
|
+
nodeToRemove?.remove()
|
|
775
|
+
selected.item.onSelect({ editor, isActive: false })
|
|
776
|
+
closeMenu()
|
|
777
|
+
})
|
|
778
|
+
},
|
|
779
|
+
[editor],
|
|
780
|
+
)
|
|
781
|
+
return (
|
|
782
|
+
<LexicalTypeaheadMenuPlugin<PickerOption>
|
|
783
|
+
onQueryChange={setQueryString}
|
|
784
|
+
onSelectOption={onSelectOption}
|
|
785
|
+
triggerFn={checkForTriggerMatch}
|
|
786
|
+
options={options}
|
|
787
|
+
menuRenderFn={(anchorElementRef, { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }) => {
|
|
788
|
+
if (!anchorElementRef.current || !options.length) return null
|
|
789
|
+
const anchorRect = anchorElementRef.current.getBoundingClientRect()
|
|
790
|
+
return createPortal(
|
|
791
|
+
<div className="fixed z-[1000]" style={{ left: anchorRect.left, top: anchorRect.bottom + 4 }}>
|
|
792
|
+
<PickerMenu options={options} selectedIndex={selectedIndex} selectOptionAndCleanUp={selectOptionAndCleanUp} setHighlightedIndex={setHighlightedIndex} />
|
|
793
|
+
</div>,
|
|
794
|
+
document.body,
|
|
795
|
+
)
|
|
796
|
+
}}
|
|
797
|
+
/>
|
|
798
|
+
)
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Clears the direction of the root and every block, so Lexical writes `dir="auto"` per block and
|
|
803
|
+
* each follows its first strong letter. Written as a history merge, so it is no change the form would save.
|
|
804
|
+
*/
|
|
805
|
+
function DirectionPlugin() {
|
|
806
|
+
const [editor] = useLexicalComposerContext()
|
|
807
|
+
useEffect(() => {
|
|
808
|
+
editor.update(() => {
|
|
809
|
+
const root = $getRoot()
|
|
810
|
+
root.setDirection(null)
|
|
811
|
+
for (const block of root.getChildren()) if ($isElementNode(block) && block.getDirection() !== null) block.setDirection(null)
|
|
812
|
+
}, { tag: HISTORY_MERGE_TAG })
|
|
813
|
+
}, [editor])
|
|
814
|
+
return null
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function ToolbarRow({ t, locale, picks, setLinkEditMode, direction }: { t: Translate; locale: string; picks: readonly RichTextPick[]; setLinkEditMode: (editing: boolean) => void; direction: RichTextDirection }) {
|
|
818
|
+
const [editor] = useLexicalComposerContext()
|
|
819
|
+
const bar = useRef<HTMLDivElement>(null)
|
|
820
|
+
const [blockType, setBlockType] = useState('paragraph')
|
|
821
|
+
const [activeEditor, setActiveEditor] = useState(editor)
|
|
822
|
+
useEffect(
|
|
823
|
+
() => activeEditor.registerCommand(SELECTION_CHANGE_COMMAND, (_payload, next) => (setActiveEditor(next), false), COMMAND_PRIORITY_CRITICAL),
|
|
824
|
+
[activeEditor],
|
|
825
|
+
)
|
|
826
|
+
// One tab stop for the whole toolbar: the button that last held focus, or the first enabled one, is tabbable. A button that changes state hands the stop on, so it never lands on a disabled one.
|
|
827
|
+
useEffect(() => {
|
|
828
|
+
const row = bar.current!
|
|
829
|
+
let last: Element | null = null
|
|
830
|
+
const enabled = () => [...row.querySelectorAll('button:not(:disabled)')] as HTMLElement[]
|
|
831
|
+
const sync = () => {
|
|
832
|
+
const all = [...row.querySelectorAll('button')] as HTMLElement[]
|
|
833
|
+
// A button disabled under focus (Undo, once nothing is left to undo) drops focus to the page: hand it to the nearest button that still takes it.
|
|
834
|
+
if (last instanceof HTMLButtonElement && last.disabled && (document.activeElement === last || document.activeElement === document.body)) {
|
|
835
|
+
const at = all.indexOf(last)
|
|
836
|
+
all.slice(at + 1).concat(all.slice(0, at).reverse()).find((one) => !(one as HTMLButtonElement).disabled)?.focus()
|
|
837
|
+
}
|
|
838
|
+
const keep = enabled().find((one) => one === last) ?? enabled()[0]
|
|
839
|
+
for (const one of all) one.tabIndex = one === keep ? 0 : -1
|
|
840
|
+
}
|
|
841
|
+
const focused = (event: FocusEvent) => ((last = event.target as Element), sync())
|
|
842
|
+
// Focus leaving the row forgets the button, so a later change cannot pull focus back in; a button disabled under focus is left to the hand-off above.
|
|
843
|
+
const left = (event: FocusEvent) => {
|
|
844
|
+
if (!row.contains(event.relatedTarget as Node | null) && !(event.target as HTMLButtonElement).disabled) last = null
|
|
845
|
+
}
|
|
846
|
+
sync()
|
|
847
|
+
const watch = new MutationObserver(sync)
|
|
848
|
+
watch.observe(row, { subtree: true, childList: true, attributeFilter: ['disabled'] })
|
|
849
|
+
row.addEventListener('focusin', focused)
|
|
850
|
+
row.addEventListener('focusout', left)
|
|
851
|
+
return () => (watch.disconnect(), row.removeEventListener('focusin', focused), row.removeEventListener('focusout', left))
|
|
852
|
+
}, [])
|
|
853
|
+
const move = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
854
|
+
// Alt+Left, Cmd+Arrow and Ctrl+Home are the browser's own, not the toolbar's.
|
|
855
|
+
if (event.altKey || event.ctrlKey || event.metaKey) return
|
|
856
|
+
const items = [...event.currentTarget.querySelectorAll('button:not(:disabled)')] as HTMLElement[]
|
|
857
|
+
const at = items.indexOf(document.activeElement as HTMLElement)
|
|
858
|
+
// A menu's own items sit in a portal, outside this row, so only a button of the row moves focus.
|
|
859
|
+
if (at < 0) return
|
|
860
|
+
const step = { ArrowRight: direction === 'rtl' ? -1 : 1, ArrowLeft: direction === 'rtl' ? 1 : -1 }[event.key]
|
|
861
|
+
const to = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : step === undefined ? undefined : (at + step + items.length) % items.length
|
|
862
|
+
if (to === undefined) return
|
|
863
|
+
event.preventDefault()
|
|
864
|
+
items[to]!.focus()
|
|
865
|
+
}
|
|
866
|
+
return (
|
|
867
|
+
// Sticky under the page's header, so no ancestor may scroll or clip (the editor box uses overflow-clip). The tint is opaque so scrolling text never shows through.
|
|
868
|
+
// On a phone it is one sideways-scrolling line, with contained width so it does not widen the page.
|
|
869
|
+
<div ref={bar} role="toolbar" aria-label={t('admin.richText.toolbar', 'Formatting')} aria-orientation="horizontal" onKeyDown={move} className="border-border bg-[color-mix(in_oklab,var(--muted)_20%,var(--background))] sticky top-[var(--header-bar-height,var(--header-height,0px))] z-9 flex flex-wrap items-center gap-1.5 border-b px-2 py-2 max-sm:flex-nowrap max-sm:overflow-x-auto max-sm:contain-inline-size max-sm:[&>*]:shrink-0">
|
|
870
|
+
<HistoryButtons activeEditor={activeEditor} t={t} />
|
|
871
|
+
<BlockDropdown toolbar={{ activeEditor, blockType, setBlockType }} t={t} locale={locale} picks={picks} />
|
|
872
|
+
<FormatButtons t={t} />
|
|
873
|
+
<LinkButton t={t} setLinkEditMode={setLinkEditMode} />
|
|
874
|
+
<ClearFormatting t={t} />
|
|
875
|
+
<AlignButtons t={t} />
|
|
876
|
+
</div>
|
|
877
|
+
)
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const configOf = (namespace: string, nodes: readonly Klass<LexicalNode>[], value: RichTextValue, editable: boolean) => ({
|
|
881
|
+
namespace,
|
|
882
|
+
editable,
|
|
883
|
+
editorState: JSON.stringify(value),
|
|
884
|
+
nodes: [...nodes],
|
|
885
|
+
onError(error: Error) {
|
|
886
|
+
throw error
|
|
887
|
+
},
|
|
888
|
+
theme,
|
|
889
|
+
})
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* What stops these nodes drawing a stored value, or nothing: the first node type the value holds
|
|
893
|
+
* that no class of the list registers, or `''` where it nests past the server's bound or Lexical's own parse refuses it.
|
|
894
|
+
*/
|
|
895
|
+
export const unreadable = (value: RichTextValue, nodes: readonly Klass<LexicalNode>[]): string | undefined => {
|
|
896
|
+
const type = unknownType({ node: value.root, types: typesOf(nodes) })
|
|
897
|
+
if (type !== undefined) return type
|
|
898
|
+
try {
|
|
899
|
+
createEditor({ nodes: [...nodes], onError: (error) => { throw error } }).parseEditorState(JSON.stringify(value))
|
|
900
|
+
return undefined
|
|
901
|
+
} catch {
|
|
902
|
+
return ''
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* One node of a value no editor here can mount, drawn as its words; a node no class registers is
|
|
908
|
+
* marked by its type. Stops at the server's depth bound with an ellipsis rather than overflowing the stack.
|
|
909
|
+
*/
|
|
910
|
+
function Walked({ node, types, inline, depth = richTextBounds.depth }: { node: RichTextNode; types: ReadonlySet<string>; inline: boolean; depth?: number }): ReactNode {
|
|
911
|
+
if (depth <= 0) return '…'
|
|
912
|
+
if (!node || typeof node !== 'object') return null
|
|
913
|
+
if (node.type === 'linebreak') return <br />
|
|
914
|
+
if (typeof node.text === 'string' && types.has(node.type)) return node.text
|
|
915
|
+
const children = Array.isArray(node.children) ? node.children : []
|
|
916
|
+
const runs = children.some((child) => typeof child?.text === 'string')
|
|
917
|
+
const drawn = children.map((child, at) => <Walked key={at} node={child} types={types} inline={runs} depth={depth - 1} />)
|
|
918
|
+
if (!types.has(node.type)) {
|
|
919
|
+
return (
|
|
920
|
+
<span data-unknown-node={node.type} className="border-destructive text-destructive rounded border border-dashed px-1">
|
|
921
|
+
{node.type}{typeof node.text === 'string' ? `: ${node.text}` : null}{drawn.length ? <> {drawn}</> : null}
|
|
922
|
+
</span>
|
|
923
|
+
)
|
|
924
|
+
}
|
|
925
|
+
return inline ? <span>{drawn}</span> : <div className="mb-2">{drawn}</div>
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** A stored value this editor cannot mount, drawn read-only by `Walked`. It reports no change, so the field stays out of every save and nothing it holds is lost. */
|
|
929
|
+
function Unreadable({ value, nodes, problem, direction, t, id, describedBy }: {
|
|
930
|
+
value: RichTextValue
|
|
931
|
+
nodes: readonly Klass<LexicalNode>[]
|
|
932
|
+
problem: string
|
|
933
|
+
direction: RichTextDirection
|
|
934
|
+
t: Translate
|
|
935
|
+
id?: string
|
|
936
|
+
describedBy?: string
|
|
937
|
+
}): ReactElement {
|
|
938
|
+
const said = problem
|
|
939
|
+
? (t('admin.richText.unknownNode', 'This text holds a "{type}" node this editor cannot draw, so it is shown as its words and cannot be changed here.') ?? '').replace('{type}', () => problem)
|
|
940
|
+
: (t('admin.richText.unreadable', 'This editor cannot read this text, so it is shown as its words and cannot be changed here.') ?? '')
|
|
941
|
+
return (
|
|
942
|
+
<div dir={direction} data-rich-text-view={id ?? ''} data-rich-text-unreadable={problem} className="grid gap-2">
|
|
943
|
+
<p role="alert" className="text-destructive text-sm">{said}</p>
|
|
944
|
+
<div id={id} aria-describedby={describedBy} className="text-sm leading-5">
|
|
945
|
+
{(Array.isArray(value.root.children) ? value.root.children : []).map((node, at) => <Walked key={at} node={node} types={typesOf(nodes)} inline={false} />)}
|
|
946
|
+
</div>
|
|
947
|
+
</div>
|
|
948
|
+
)
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* The editor, over the screen's nodes and picks (`RichTextContext`) unless handed its own.
|
|
953
|
+
* Uncontrolled past its first value, as Lexical is. A stored value it cannot mount is drawn by `Unreadable` and reports nothing.
|
|
954
|
+
*/
|
|
955
|
+
export function RichTextEditor({ value, onChange, direction, t, locale, id, placeholder, describedBy, invalid, required, nodes, picks, control }: {
|
|
956
|
+
value: unknown
|
|
957
|
+
onChange: (value: RichTextValue) => void
|
|
958
|
+
direction: RichTextDirection
|
|
959
|
+
t: Translate
|
|
960
|
+
locale: string
|
|
961
|
+
id?: string
|
|
962
|
+
placeholder?: string
|
|
963
|
+
describedBy?: string
|
|
964
|
+
invalid?: boolean
|
|
965
|
+
required?: boolean
|
|
966
|
+
nodes?: readonly Klass<LexicalNode>[]
|
|
967
|
+
picks?: readonly RichTextPick[]
|
|
968
|
+
/** The field's control props, which the screen's `Plugin` is handed. */
|
|
969
|
+
control?: ControlProps
|
|
970
|
+
}): ReactElement {
|
|
971
|
+
const provided = use(RichTextContext)
|
|
972
|
+
const { Plugin } = provided
|
|
973
|
+
const drawn = nodes ?? provided.nodes
|
|
974
|
+
const [held] = useState(() => valueOf(value))
|
|
975
|
+
const [problem] = useState(() => unreadable(held, drawn))
|
|
976
|
+
// Built only over a value these nodes read, so a value nested past the bound never reaches `JSON.stringify` or Lexical.
|
|
977
|
+
const [initial] = useState(() => (problem === undefined ? configOf('MithaqRichText', drawn, held, true) : undefined))
|
|
978
|
+
const [isLinkEditMode, setIsLinkEditMode] = useState(false)
|
|
979
|
+
const [anchor, setAnchor] = useState<HTMLDivElement | null>(null)
|
|
980
|
+
if (problem !== undefined || !initial) return <Unreadable value={held} nodes={drawn} problem={problem ?? ''} direction={direction} t={t} id={id} describedBy={describedBy} />
|
|
981
|
+
return (
|
|
982
|
+
<div className="border-input bg-background overflow-clip rounded-lg border shadow-xs" dir={direction} data-rich-text={id}>
|
|
983
|
+
<LexicalComposer initialConfig={initial}>
|
|
984
|
+
<ToolbarRow t={t} locale={locale} picks={picks ?? provided.picks} setLinkEditMode={setIsLinkEditMode} direction={direction} />
|
|
985
|
+
<div className="relative" ref={setAnchor}>
|
|
986
|
+
<RichTextPlugin
|
|
987
|
+
contentEditable={
|
|
988
|
+
<ContentEditable
|
|
989
|
+
id={id}
|
|
990
|
+
// Named by the field's label, since a `for` cannot reach a div.
|
|
991
|
+
aria-labelledby={id ? `${id}-label` : undefined}
|
|
992
|
+
aria-describedby={describedBy}
|
|
993
|
+
aria-invalid={invalid ? true : undefined}
|
|
994
|
+
aria-required={required ? true : undefined}
|
|
995
|
+
aria-placeholder={placeholder ?? ''}
|
|
996
|
+
className="min-h-40 px-4 py-3 text-sm leading-6 outline-none"
|
|
997
|
+
placeholder={<div className="text-muted-foreground pointer-events-none absolute start-0 top-0 overflow-hidden px-4 py-3 text-sm text-ellipsis select-none">{placeholder ?? ''}</div>}
|
|
998
|
+
/>
|
|
999
|
+
}
|
|
1000
|
+
ErrorBoundary={LexicalErrorBoundary}
|
|
1001
|
+
/>
|
|
1002
|
+
</div>
|
|
1003
|
+
{/* A value holding an unconfirmed link is never handed on, so an autosave while the link editor is open sends no placeholder the server refuses. */}
|
|
1004
|
+
<OnChangePlugin ignoreSelectionChange onChange={(state) => holdsPlaceholder(state) || onChange(state.toJSON() as unknown as RichTextValue)} />
|
|
1005
|
+
<HistoryPlugin />
|
|
1006
|
+
<ListPlugin />
|
|
1007
|
+
<CheckListPlugin />
|
|
1008
|
+
<LinkPlugin validateUrl={safeUrl} />
|
|
1009
|
+
<FloatingLinkEditorPlugin anchorElem={anchor} isLinkEditMode={isLinkEditMode} setIsLinkEditMode={setIsLinkEditMode} t={t} />
|
|
1010
|
+
<HorizontalRulePlugin />
|
|
1011
|
+
<SlashMenu locale={locale} picks={picks ?? provided.picks} />
|
|
1012
|
+
<DirectionPlugin />
|
|
1013
|
+
{Plugin && control ? <Plugin {...control} /> : null}
|
|
1014
|
+
</LexicalComposer>
|
|
1015
|
+
</div>
|
|
1016
|
+
)
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* The read view: the same nodes, never editable, remounted when the value changes. A link is drawn
|
|
1021
|
+
* through Lexical's `sanitizeUrl`, which answers `about:blank` for unsupported schemes, so a
|
|
1022
|
+
* `javascript:` or `data:` address that reached a column past the server's check is never followed.
|
|
1023
|
+
*/
|
|
1024
|
+
export function RichTextViewer({ value, direction, t, className, id, describedBy, nodes }: {
|
|
1025
|
+
value: unknown
|
|
1026
|
+
direction: RichTextDirection
|
|
1027
|
+
t: Translate
|
|
1028
|
+
className?: string
|
|
1029
|
+
id?: string
|
|
1030
|
+
describedBy?: string
|
|
1031
|
+
nodes?: readonly Klass<LexicalNode>[]
|
|
1032
|
+
}): ReactElement {
|
|
1033
|
+
const drawn = nodes ?? use(RichTextContext).nodes
|
|
1034
|
+
const held = useMemo(() => valueOf(value), [value])
|
|
1035
|
+
// Asked before serializing, so a value nested past the bound never reaches `JSON.stringify` or Lexical.
|
|
1036
|
+
const problem = useMemo(() => unreadable(held, drawn), [held, drawn])
|
|
1037
|
+
if (problem !== undefined) return <Unreadable value={held} nodes={drawn} problem={problem} direction={direction} t={t} id={id} describedBy={describedBy} />
|
|
1038
|
+
const serialized = JSON.stringify(held)
|
|
1039
|
+
return (
|
|
1040
|
+
<div dir={direction} data-rich-text-view={id ?? ''}>
|
|
1041
|
+
<LexicalComposer key={serialized} initialConfig={configOf('MithaqRichTextViewer', drawn, held, false)}>
|
|
1042
|
+
<RichTextPlugin
|
|
1043
|
+
contentEditable={<ContentEditable id={id} aria-describedby={describedBy} className={cn('text-sm leading-5 outline-none [&_h1]:text-xl [&_h2]:text-lg [&_h3]:text-base [&_p:last-child]:mb-0', className)} />}
|
|
1044
|
+
ErrorBoundary={LexicalErrorBoundary}
|
|
1045
|
+
/>
|
|
1046
|
+
<ListPlugin />
|
|
1047
|
+
<CheckListPlugin />
|
|
1048
|
+
<LinkPlugin validateUrl={safeUrl} />
|
|
1049
|
+
<DirectionPlugin />
|
|
1050
|
+
</LexicalComposer>
|
|
1051
|
+
</div>
|
|
1052
|
+
)
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/** The `richText` kind's control: the editor, or the viewer where the field is read only. An editor holding nothing but empty paragraphs reports `null`. */
|
|
1056
|
+
export function RichTextControl(props: ControlProps): ReactElement {
|
|
1057
|
+
const { describedBy, error, id, locale, placeholder, readOnly, t, value } = props
|
|
1058
|
+
const direction = useDirection() === 'rtl' ? 'rtl' : 'ltr'
|
|
1059
|
+
const change = changeOf(props)
|
|
1060
|
+
if (readOnly) return <RichTextViewer value={value} direction={direction} t={t} id={id} describedBy={describedBy} />
|
|
1061
|
+
return (
|
|
1062
|
+
<RichTextEditor
|
|
1063
|
+
value={value}
|
|
1064
|
+
onChange={(next) => change(blank(next) ? null : next)}
|
|
1065
|
+
direction={direction}
|
|
1066
|
+
t={t}
|
|
1067
|
+
locale={locale ?? 'en'}
|
|
1068
|
+
id={id}
|
|
1069
|
+
placeholder={placeholder}
|
|
1070
|
+
describedBy={describedBy}
|
|
1071
|
+
invalid={!!error}
|
|
1072
|
+
required={ariaRequired(props.field) === true}
|
|
1073
|
+
control={props}
|
|
1074
|
+
/>
|
|
1075
|
+
)
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/** A rich text value as a record page reads it: the viewer, over the page's direction, and nothing where it holds none. */
|
|
1079
|
+
export function RichTextValueView({ value, t }: { value: unknown; t: Translate }): ReactNode {
|
|
1080
|
+
const direction = useDirection() === 'rtl' ? 'rtl' : 'ltr'
|
|
1081
|
+
return value ? <RichTextViewer value={value} direction={direction} t={t} /> : null
|
|
1082
|
+
}
|
|
1083
|
+
// mithaq-admin b06d8d335d306c07556f8f2b81fba20ba7ded198 0709e5fd4e8b0eaeda128c63d9bb3d4bd885ef9f053b7374a40c0223e7aee874
|