@valentinkolb/cloud 0.4.0 → 0.5.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/package.json +18 -6
- package/scripts/preload.ts +78 -23
- package/src/_internal/define-app.ts +53 -46
- package/src/api/accounts-entities.ts +4 -0
- package/src/api/admin-core-settings.ts +98 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/auth/schemas.ts +24 -0
- package/src/api/auth.ts +116 -13
- package/src/api/index.ts +7 -2
- package/src/api/me.ts +203 -14
- package/src/api/search/schemas.ts +1 -0
- package/src/api/search.ts +62 -8
- package/src/config/ssr.ts +2 -9
- package/src/contracts/announcements.test.ts +37 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +3 -2
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/shared.ts +108 -1
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/server/api/index.ts +1 -1
- package/src/server/api/respond.ts +50 -10
- package/src/server/index.ts +44 -38
- package/src/server/middleware/auth.ts +98 -9
- package/src/server/middleware/index.ts +2 -1
- package/src/server/middleware/settings.ts +26 -0
- package/src/server/services/access.test.ts +197 -0
- package/src/server/services/access.ts +254 -6
- package/src/server/services/index.ts +14 -11
- package/src/server/services/pagination.ts +22 -0
- package/src/server/time.ts +45 -0
- package/src/services/account-lifecycle/index.ts +142 -18
- package/src/services/accounts/app.ts +658 -170
- package/src/services/accounts/authz.test.ts +77 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/entities.ts +84 -5
- package/src/services/accounts/groups.ts +30 -24
- package/src/services/accounts/model.test.ts +30 -0
- package/src/services/accounts/switching.test.ts +14 -0
- package/src/services/accounts/switching.ts +15 -6
- package/src/services/accounts/users.ts +75 -52
- package/src/services/announcements/index.test.ts +32 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/audit/index.test.ts +84 -0
- package/src/services/audit/index.ts +431 -0
- package/src/services/auth-flows/index.ts +9 -2
- package/src/services/auth-flows/ipa.ts +47 -7
- package/src/services/auth-flows/magic-link.ts +92 -20
- package/src/services/auth-flows/password-reset.ts +284 -0
- package/src/services/auth-flows/proxy-return.test.ts +24 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/gateway.ts +162 -0
- package/src/services/index.ts +44 -2
- package/src/services/ipa/effective-groups.test.ts +33 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/profile.ts +45 -3
- package/src/services/ipa/search.ts +3 -5
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.test.ts +32 -0
- package/src/services/ipa/sync-planning.ts +22 -0
- package/src/services/ipa/sync.ts +110 -38
- package/src/services/notifications/index.ts +82 -11
- package/src/services/oauth-tokens.ts +104 -0
- package/src/services/postgres.ts +21 -6
- package/src/services/providers/local/auth.test.ts +22 -0
- package/src/services/providers/local/auth.ts +46 -3
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.test.ts +210 -0
- package/src/services/service-account-credentials.ts +715 -0
- package/src/services/service-accounts.ts +188 -0
- package/src/services/session/index.ts +7 -8
- package/src/services/settings/app.ts +4 -20
- package/src/services/settings/defaults.ts +79 -22
- package/src/services/settings/store.ts +47 -0
- package/src/services/weather/forecast.ts +40 -7
- package/src/services/webauthn.test.ts +36 -0
- package/src/services/webauthn.ts +384 -0
- package/src/shared/icons.ts +391 -100
- package/src/shared/index.ts +7 -0
- package/src/shared/markdown/extensions/code.ts +38 -1
- package/src/shared/markdown/extensions/images.ts +39 -3
- package/src/shared/markdown/extensions/info-blocks.ts +5 -5
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +79 -58
- package/src/shared/markdown/formula.test.ts +1089 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +76 -2
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/redirect.test.ts +58 -0
- package/src/shared/redirect.ts +56 -0
- package/src/shared/theme.test.ts +24 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +7 -3
- package/src/ssr/AdminSidebar.tsx +115 -49
- package/src/ssr/AppLaunchpad.island.tsx +176 -0
- package/src/ssr/Footer.island.tsx +3 -8
- package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
- package/src/ssr/GlobalSearchDialog.tsx +545 -117
- package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
- package/src/ssr/Layout.tsx +74 -66
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutHelp.tsx +266 -0
- package/src/ssr/NavMenu.island.tsx +0 -39
- package/src/ssr/ThemeToggleRail.island.tsx +3 -3
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/islands/index.ts +13 -0
- package/src/styles/base-popover.css +5 -2
- package/src/styles/effects.css +87 -6
- package/src/styles/global.css +146 -9
- package/src/styles/input.css +3 -1
- package/src/styles/utilities-buttons.css +133 -27
- package/src/styles/utilities-code-display.css +67 -0
- package/src/styles/utilities-completion.css +223 -0
- package/src/styles/utilities-detail.css +73 -0
- package/src/styles/utilities-feedback.css +16 -15
- package/src/styles/utilities-layout.css +42 -2
- package/src/styles/utilities-markdown-editor.css +472 -0
- package/src/styles/utilities-navigation.css +63 -8
- package/src/styles/utilities-script.css +84 -0
- package/src/styles/utilities-table-tile.css +229 -0
- package/src/types/ambient.d.ts +9 -0
- package/src/ui/completion/behaviors.test.ts +95 -0
- package/src/ui/completion/behaviors.ts +205 -0
- package/src/ui/completion/engine.ts +368 -0
- package/src/ui/completion/index.ts +40 -0
- package/src/ui/completion/overlay.ts +92 -0
- package/src/ui/dialog-core.ts +173 -45
- package/src/ui/filter/FilterChip.tsx +42 -40
- package/src/ui/index.ts +11 -12
- package/src/ui/input/AutocompleteEditor.tsx +656 -0
- package/src/ui/input/CheckboxCard.tsx +91 -0
- package/src/ui/input/Combobox.tsx +375 -0
- package/src/ui/input/DatePicker.tsx +846 -0
- package/src/ui/input/DateTimeInput.tsx +29 -4
- package/src/ui/input/FileDropzone.tsx +116 -0
- package/src/ui/input/IconInput.tsx +116 -0
- package/src/ui/input/ImageInput.tsx +19 -2
- package/src/ui/input/MultiSelectInput.tsx +448 -0
- package/src/ui/input/NumberInput.tsx +417 -61
- package/src/ui/input/SegmentedControl.tsx +2 -2
- package/src/ui/input/Select.tsx +172 -10
- package/src/ui/input/Slider.tsx +3 -4
- package/src/ui/input/Switch.tsx +3 -2
- package/src/ui/input/TemplateEditor.tsx +212 -0
- package/src/ui/input/TextInput.tsx +144 -13
- package/src/ui/input/index.ts +53 -8
- package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
- package/src/ui/input/markdown/Toolbar.tsx +90 -0
- package/src/ui/input/markdown/actions.ts +233 -0
- package/src/ui/input/markdown/active-formats.ts +94 -0
- package/src/ui/input/markdown/behaviors.ts +193 -0
- package/src/ui/input/markdown/code-zone.ts +23 -0
- package/src/ui/input/markdown/highlight.ts +316 -0
- package/src/ui/layout.ts +22 -0
- package/src/ui/misc/AppOverview.tsx +105 -0
- package/src/ui/misc/AppWorkspace.tsx +607 -0
- package/src/ui/misc/Calendar.tsx +1291 -0
- package/src/ui/misc/Chart.tsx +162 -0
- package/src/ui/misc/CodeDisplay.tsx +54 -0
- package/src/ui/misc/ContextMenu.tsx +2 -2
- package/src/ui/misc/DataTable.tsx +269 -0
- package/src/ui/misc/DockWorkspace.tsx +425 -0
- package/src/ui/misc/Docs.tsx +153 -0
- package/src/ui/misc/Dropdown.tsx +2 -2
- package/src/ui/misc/EntitySearch.tsx +260 -129
- package/src/ui/misc/LinkCard.tsx +14 -2
- package/src/ui/misc/LogEntriesTable.tsx +34 -31
- package/src/ui/misc/Pagination.tsx +31 -12
- package/src/ui/misc/PanelDialog.tsx +109 -0
- package/src/ui/misc/Panes.tsx +873 -0
- package/src/ui/misc/PermissionEditor.tsx +358 -262
- package/src/ui/misc/Placeholder.tsx +40 -0
- package/src/ui/misc/ProgressBar.tsx +1 -1
- package/src/ui/misc/ResourceApiKeys.tsx +260 -0
- package/src/ui/misc/SettingsModal.tsx +150 -0
- package/src/ui/misc/StatCell.tsx +182 -40
- package/src/ui/misc/StatGrid.tsx +149 -0
- package/src/ui/misc/StructuredDataPreview.tsx +107 -0
- package/src/ui/misc/code-highlight.ts +213 -0
- package/src/ui/misc/index.ts +93 -12
- package/src/ui/prompts.tsx +362 -312
- package/src/ui/toast.ts +384 -0
- package/src/ui/widgets/Widget.tsx +12 -4
- package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
- package/src/ui/ipa/GroupView.tsx +0 -36
- package/src/ui/ipa/LoginBtn.tsx +0 -16
- package/src/ui/ipa/UserView.tsx +0 -58
- package/src/ui/ipa/index.ts +0 -4
- package/src/ui/navigation.ts +0 -32
- package/src/ui/sidebar.tsx +0 -468
- /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toolbar button strip for the markdown editor. Mouse-only by design:
|
|
3
|
+
* each button has `tabIndex=-1` so Tab navigates AROUND the editor's
|
|
4
|
+
* toolbar straight to the textarea body. Keyboard users get the same
|
|
5
|
+
* actions via `Cmd/Ctrl + B/I/E/K` and the shift-digit shortcuts.
|
|
6
|
+
*
|
|
7
|
+
* Buttons display in an active state (blue tint + faint background)
|
|
8
|
+
* when `activeFormats` contains their `id` — feedback that the caret
|
|
9
|
+
* currently sits inside a styled span. The set is computed by
|
|
10
|
+
* `active-formats.ts` and pushed in from the host component.
|
|
11
|
+
*/
|
|
12
|
+
import { For } from "solid-js";
|
|
13
|
+
import {
|
|
14
|
+
toggleBold,
|
|
15
|
+
toggleItalic,
|
|
16
|
+
toggleCode,
|
|
17
|
+
insertLink,
|
|
18
|
+
toggleHeading,
|
|
19
|
+
toggleBulletList,
|
|
20
|
+
toggleNumberedList,
|
|
21
|
+
toggleQuote,
|
|
22
|
+
} from "./actions";
|
|
23
|
+
|
|
24
|
+
type ToolbarProps = {
|
|
25
|
+
/** Reactive accessor returning the textarea element (or null before mount). */
|
|
26
|
+
textarea: () => HTMLTextAreaElement | null;
|
|
27
|
+
/** Reactive set of "currently active" format IDs at the caret. The
|
|
28
|
+
* button whose `id` is in the set renders in the active visual state
|
|
29
|
+
* (overtype convention — gives the user feedback that the cursor sits
|
|
30
|
+
* inside a styled span). */
|
|
31
|
+
activeFormats?: () => Set<string>;
|
|
32
|
+
disabled?: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type Tool =
|
|
36
|
+
| { kind: "btn"; id: string; icon: string; title: string; run: (ta: HTMLTextAreaElement) => void }
|
|
37
|
+
| { kind: "sep" };
|
|
38
|
+
|
|
39
|
+
const TOOLS: Tool[] = [
|
|
40
|
+
{ kind: "btn", id: "bold", icon: "ti ti-bold", title: "Bold (Ctrl/Cmd+B)", run: toggleBold },
|
|
41
|
+
{ kind: "btn", id: "italic", icon: "ti ti-italic", title: "Italic (Ctrl/Cmd+I)", run: toggleItalic },
|
|
42
|
+
{ kind: "btn", id: "code", icon: "ti ti-code", title: "Inline code (Ctrl/Cmd+E)", run: toggleCode },
|
|
43
|
+
{ kind: "btn", id: "link", icon: "ti ti-link", title: "Link (Ctrl/Cmd+K)", run: (ta) => insertLink(ta) },
|
|
44
|
+
{ kind: "sep" },
|
|
45
|
+
{ kind: "btn", id: "h1", icon: "ti ti-h-1", title: "Heading 1 (Ctrl/Cmd+Shift+1)", run: (ta) => toggleHeading(ta, 1) },
|
|
46
|
+
{ kind: "btn", id: "h2", icon: "ti ti-h-2", title: "Heading 2 (Ctrl/Cmd+Shift+2)", run: (ta) => toggleHeading(ta, 2) },
|
|
47
|
+
{ kind: "btn", id: "h3", icon: "ti ti-h-3", title: "Heading 3 (Ctrl/Cmd+Shift+3)", run: (ta) => toggleHeading(ta, 3) },
|
|
48
|
+
{ kind: "sep" },
|
|
49
|
+
{ kind: "btn", id: "bullet", icon: "ti ti-list", title: "Bullet list (Ctrl/Cmd+Shift+8)", run: toggleBulletList },
|
|
50
|
+
{ kind: "btn", id: "ordered", icon: "ti ti-list-numbers", title: "Numbered list (Ctrl/Cmd+Shift+7)", run: toggleNumberedList },
|
|
51
|
+
{ kind: "btn", id: "quote", icon: "ti ti-quote", title: "Quote", run: toggleQuote },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
export default function Toolbar(props: ToolbarProps) {
|
|
55
|
+
return (
|
|
56
|
+
<div class="md-editor-toolbar" role="toolbar" aria-label="Markdown formatting">
|
|
57
|
+
<For each={TOOLS}>
|
|
58
|
+
{(tool) =>
|
|
59
|
+
tool.kind === "sep" ? (
|
|
60
|
+
<span class="md-editor-tool-sep" aria-hidden="true" />
|
|
61
|
+
) : (
|
|
62
|
+
<button
|
|
63
|
+
type="button"
|
|
64
|
+
class="md-editor-tool"
|
|
65
|
+
title={tool.title}
|
|
66
|
+
aria-label={tool.title}
|
|
67
|
+
aria-pressed={props.activeFormats?.().has(tool.id) ? "true" : undefined}
|
|
68
|
+
disabled={props.disabled}
|
|
69
|
+
// tabIndex=-1 so Tab doesn't land on 10 formatting buttons
|
|
70
|
+
// before reaching the editor body. Keyboard users have all
|
|
71
|
+
// the same actions available via Cmd/Ctrl shortcuts; the
|
|
72
|
+
// toolbar stays a mouse-friendly affordance.
|
|
73
|
+
tabIndex={-1}
|
|
74
|
+
// Prevent the button from stealing focus from the textarea —
|
|
75
|
+
// we want the textarea to stay focused so the cursor stays
|
|
76
|
+
// visible during action.
|
|
77
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
78
|
+
onClick={() => {
|
|
79
|
+
const ta = props.textarea();
|
|
80
|
+
if (ta) tool.run(ta);
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
83
|
+
<i class={tool.icon} />
|
|
84
|
+
</button>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
</For>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text manipulations for the markdown editor toolbar / shortcuts.
|
|
3
|
+
*
|
|
4
|
+
* Every action operates on a real `<textarea>` element and uses
|
|
5
|
+
* `document.execCommand("insertText", …)` for the actual replacement.
|
|
6
|
+
* Although `execCommand` is technically deprecated, it remains the only
|
|
7
|
+
* way to mutate a textarea while keeping the browser's NATIVE undo
|
|
8
|
+
* stack intact — replacing `.value` directly clears the user's undo
|
|
9
|
+
* history, which is hostile to non-technical users editing prose. All
|
|
10
|
+
* actions are otherwise pure DOM ops with no Solid involvement, so they
|
|
11
|
+
* can be called from event handlers in any framework.
|
|
12
|
+
*
|
|
13
|
+
* Selection handling principles:
|
|
14
|
+
* - Read `selectionStart` / `selectionEnd` to capture state.
|
|
15
|
+
* - Compute the replacement string.
|
|
16
|
+
* - `textarea.setSelectionRange(start, end)` to widen the selection
|
|
17
|
+
* to cover what we want to replace.
|
|
18
|
+
* - `document.execCommand("insertText", false, replacement)`.
|
|
19
|
+
* - Set the cursor / selection to the desired final position.
|
|
20
|
+
* - Dispatch an `input` event so the host component re-renders the
|
|
21
|
+
* preview and notifies its `onInput` listener.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const replaceRange = (ta: HTMLTextAreaElement, start: number, end: number, replacement: string): void => {
|
|
25
|
+
ta.focus();
|
|
26
|
+
ta.setSelectionRange(start, end);
|
|
27
|
+
// `document.execCommand("insertText", …)` fires an `input` event on
|
|
28
|
+
// all major browsers — no manual dispatch needed on the happy path.
|
|
29
|
+
// It also keeps the textarea's native undo history intact.
|
|
30
|
+
const ok = document.execCommand("insertText", false, replacement);
|
|
31
|
+
if (!ok) {
|
|
32
|
+
// Fallback: direct assignment loses undo history AND does NOT
|
|
33
|
+
// fire input on its own, so we synthesise the event so consumers
|
|
34
|
+
// and the editor's onInput listener still see the change.
|
|
35
|
+
const before = ta.value.slice(0, start);
|
|
36
|
+
const after = ta.value.slice(end);
|
|
37
|
+
ta.value = before + replacement + after;
|
|
38
|
+
const caret = start + replacement.length;
|
|
39
|
+
ta.setSelectionRange(caret, caret);
|
|
40
|
+
ta.dispatchEvent(new Event("input", { bubbles: true }));
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type LineInfo = { lineStart: number; lineEnd: number; line: string };
|
|
45
|
+
|
|
46
|
+
const lineAt = (value: string, pos: number): LineInfo => {
|
|
47
|
+
const lineStart = value.lastIndexOf("\n", pos - 1) + 1;
|
|
48
|
+
const nlAfter = value.indexOf("\n", pos);
|
|
49
|
+
const lineEnd = nlAfter === -1 ? value.length : nlAfter;
|
|
50
|
+
return { lineStart, lineEnd, line: value.slice(lineStart, lineEnd) };
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Expand the selection to cover full lines for line-level toggles. */
|
|
54
|
+
const selectedLineRange = (ta: HTMLTextAreaElement): { start: number; end: number; lines: string[] } => {
|
|
55
|
+
const value = ta.value;
|
|
56
|
+
const selStart = ta.selectionStart;
|
|
57
|
+
const selEnd = ta.selectionEnd;
|
|
58
|
+
const startLine = lineAt(value, selStart);
|
|
59
|
+
// If selEnd is exactly at a line start (just after \n), don't expand
|
|
60
|
+
// forward to the next line — selecting "line1\n" then toggling should
|
|
61
|
+
// affect only line1.
|
|
62
|
+
const adjustedEnd = selEnd > selStart && selEnd > 0 && value[selEnd - 1] === "\n" ? selEnd - 1 : selEnd;
|
|
63
|
+
const endLine = lineAt(value, adjustedEnd);
|
|
64
|
+
const block = value.slice(startLine.lineStart, endLine.lineEnd);
|
|
65
|
+
return { start: startLine.lineStart, end: endLine.lineEnd, lines: block.split("\n") };
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/* ────────────────────────────────────────────────────────────────────
|
|
69
|
+
* Inline wrappers (bold / italic / code)
|
|
70
|
+
* ──────────────────────────────────────────────────────────────────── */
|
|
71
|
+
|
|
72
|
+
const toggleInlineWrap = (ta: HTMLTextAreaElement, marker: string, placeholder: string): void => {
|
|
73
|
+
const value = ta.value;
|
|
74
|
+
const selStart = ta.selectionStart;
|
|
75
|
+
const selEnd = ta.selectionEnd;
|
|
76
|
+
const mlen = marker.length;
|
|
77
|
+
|
|
78
|
+
// Already wrapped? Check chars just outside the selection.
|
|
79
|
+
const outsideBefore = value.slice(Math.max(0, selStart - mlen), selStart);
|
|
80
|
+
const outsideAfter = value.slice(selEnd, selEnd + mlen);
|
|
81
|
+
if (outsideBefore === marker && outsideAfter === marker) {
|
|
82
|
+
// Unwrap: remove the marker on each side
|
|
83
|
+
replaceRange(ta, selStart - mlen, selEnd + mlen, value.slice(selStart, selEnd));
|
|
84
|
+
const newStart = selStart - mlen;
|
|
85
|
+
const newEnd = selEnd - mlen;
|
|
86
|
+
ta.setSelectionRange(newStart, newEnd);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Or inside-wrapped: selection contains the markers (e.g. user
|
|
91
|
+
// selected `**bold**` itself). Strip if the selection starts and
|
|
92
|
+
// ends with the marker.
|
|
93
|
+
const sel = value.slice(selStart, selEnd);
|
|
94
|
+
if (sel.length >= mlen * 2 && sel.startsWith(marker) && sel.endsWith(marker)) {
|
|
95
|
+
const stripped = sel.slice(mlen, sel.length - mlen);
|
|
96
|
+
replaceRange(ta, selStart, selEnd, stripped);
|
|
97
|
+
ta.setSelectionRange(selStart, selStart + stripped.length);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Wrap. If the selection is empty, insert `marker + placeholder +
|
|
102
|
+
// marker` and select the placeholder so the user can type to replace.
|
|
103
|
+
if (selStart === selEnd) {
|
|
104
|
+
const insert = `${marker}${placeholder}${marker}`;
|
|
105
|
+
replaceRange(ta, selStart, selEnd, insert);
|
|
106
|
+
ta.setSelectionRange(selStart + mlen, selStart + mlen + placeholder.length);
|
|
107
|
+
} else {
|
|
108
|
+
const wrapped = `${marker}${sel}${marker}`;
|
|
109
|
+
replaceRange(ta, selStart, selEnd, wrapped);
|
|
110
|
+
ta.setSelectionRange(selStart + mlen, selStart + mlen + sel.length);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const toggleBold = (ta: HTMLTextAreaElement): void => toggleInlineWrap(ta, "**", "bold text");
|
|
115
|
+
export const toggleItalic = (ta: HTMLTextAreaElement): void => toggleInlineWrap(ta, "*", "italic text");
|
|
116
|
+
export const toggleCode = (ta: HTMLTextAreaElement): void => toggleInlineWrap(ta, "`", "code");
|
|
117
|
+
|
|
118
|
+
/* ────────────────────────────────────────────────────────────────────
|
|
119
|
+
* Links
|
|
120
|
+
* ──────────────────────────────────────────────────────────────────── */
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Insert `[selection](url)` — or `[label](url)` when nothing's selected.
|
|
124
|
+
* After insertion the cursor lands inside the URL parens so the user
|
|
125
|
+
* can immediately paste / type the destination.
|
|
126
|
+
*/
|
|
127
|
+
export const insertLink = (ta: HTMLTextAreaElement, url?: string): void => {
|
|
128
|
+
const value = ta.value;
|
|
129
|
+
const selStart = ta.selectionStart;
|
|
130
|
+
const selEnd = ta.selectionEnd;
|
|
131
|
+
const sel = value.slice(selStart, selEnd);
|
|
132
|
+
const label = sel || "link";
|
|
133
|
+
const finalUrl = url ?? "";
|
|
134
|
+
const insert = `[${label}](${finalUrl})`;
|
|
135
|
+
replaceRange(ta, selStart, selEnd, insert);
|
|
136
|
+
// Land the cursor inside the parens — between `(` and `)`.
|
|
137
|
+
const caret = selStart + label.length + 3 + finalUrl.length;
|
|
138
|
+
if (finalUrl) {
|
|
139
|
+
// URL was provided (e.g. from smart paste). Select the label so
|
|
140
|
+
// the user can keep typing if they want to rename it.
|
|
141
|
+
ta.setSelectionRange(selStart + 1, selStart + 1 + label.length);
|
|
142
|
+
} else {
|
|
143
|
+
ta.setSelectionRange(caret, caret);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/* ────────────────────────────────────────────────────────────────────
|
|
148
|
+
* Line-level toggles (headers, lists, quote)
|
|
149
|
+
* ──────────────────────────────────────────────────────────────────── */
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Toggle a single-prefix line marker (`> `, `# `, etc.).
|
|
153
|
+
* - If every selected line already has the marker (after its indent),
|
|
154
|
+
* strip the marker but keep the indent.
|
|
155
|
+
* - Otherwise, insert the marker AFTER each line's leading whitespace
|
|
156
|
+
* so indented lines stay indented.
|
|
157
|
+
*/
|
|
158
|
+
const togglePrefix = (ta: HTMLTextAreaElement, prefix: string): void => {
|
|
159
|
+
const { start, end, lines } = selectedLineRange(ta);
|
|
160
|
+
const splitIndent = (l: string): [string, string] => {
|
|
161
|
+
const m = /^(\s*)(.*)$/.exec(l)!;
|
|
162
|
+
return [m[1]!, m[2]!];
|
|
163
|
+
};
|
|
164
|
+
const allHave = lines.every((l) => {
|
|
165
|
+
const [, body] = splitIndent(l);
|
|
166
|
+
return body.startsWith(prefix);
|
|
167
|
+
});
|
|
168
|
+
const transformed = lines.map((l) => {
|
|
169
|
+
const [indent, body] = splitIndent(l);
|
|
170
|
+
return allHave ? indent + body.slice(prefix.length) : indent + prefix + body;
|
|
171
|
+
});
|
|
172
|
+
const replacement = transformed.join("\n");
|
|
173
|
+
replaceRange(ta, start, end, replacement);
|
|
174
|
+
ta.setSelectionRange(start, start + replacement.length);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Toggle a heading at the current line. `level` is 1, 2, or 3. If the
|
|
179
|
+
* line already has the exact level, strip it (toggle off). If it has a
|
|
180
|
+
* different heading level, replace it. Else prepend.
|
|
181
|
+
*/
|
|
182
|
+
export const toggleHeading = (ta: HTMLTextAreaElement, level: 1 | 2 | 3): void => {
|
|
183
|
+
const value = ta.value;
|
|
184
|
+
const oldCaret = ta.selectionStart;
|
|
185
|
+
const { lineStart, lineEnd, line } = lineAt(value, oldCaret);
|
|
186
|
+
const want = "#".repeat(level) + " ";
|
|
187
|
+
const headerRe = /^(#{1,6})\s/;
|
|
188
|
+
const existing = headerRe.exec(line);
|
|
189
|
+
let newLine: string;
|
|
190
|
+
if (existing && existing[1]!.length === level) {
|
|
191
|
+
newLine = line.slice(existing[0].length);
|
|
192
|
+
} else if (existing) {
|
|
193
|
+
newLine = want + line.slice(existing[0].length);
|
|
194
|
+
} else {
|
|
195
|
+
newLine = want + line;
|
|
196
|
+
}
|
|
197
|
+
replaceRange(ta, lineStart, lineEnd, newLine);
|
|
198
|
+
// Caret math must use the PRE-mutation caret. `replaceRange` moves
|
|
199
|
+
// the selection to the end of the inserted text; reading
|
|
200
|
+
// selectionStart now would lose the user's original position. Clamp
|
|
201
|
+
// to the new line bounds so we never end up before the line start.
|
|
202
|
+
const delta = newLine.length - line.length;
|
|
203
|
+
const desired = Math.max(lineStart, oldCaret + delta);
|
|
204
|
+
const newCaret = Math.min(desired, lineStart + newLine.length);
|
|
205
|
+
ta.setSelectionRange(newCaret, newCaret);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
export const toggleBulletList = (ta: HTMLTextAreaElement): void => togglePrefix(ta, "- ");
|
|
209
|
+
export const toggleQuote = (ta: HTMLTextAreaElement): void => togglePrefix(ta, "> ");
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Numbered list toggle. Renumber from 1 across the selection when adding;
|
|
213
|
+
* strip the `N. ` prefix when removing. Leading indent is preserved on
|
|
214
|
+
* both code paths so indented lists keep their nesting.
|
|
215
|
+
*/
|
|
216
|
+
export const toggleNumberedList = (ta: HTMLTextAreaElement): void => {
|
|
217
|
+
const { start, end, lines } = selectedLineRange(ta);
|
|
218
|
+
const lineRe = /^(\s*)(\d+\.\s)?(.*)$/;
|
|
219
|
+
const parts = lines.map((l) => {
|
|
220
|
+
const m = lineRe.exec(l)!;
|
|
221
|
+
return { indent: m[1] ?? "", existingMarker: m[2], rest: m[3] ?? "" };
|
|
222
|
+
});
|
|
223
|
+
const allNumbered = parts.every((p) => !!p.existingMarker);
|
|
224
|
+
let transformed: string[];
|
|
225
|
+
if (allNumbered) {
|
|
226
|
+
transformed = parts.map((p) => p.indent + p.rest);
|
|
227
|
+
} else {
|
|
228
|
+
transformed = parts.map((p, i) => `${p.indent}${i + 1}. ${p.rest}`);
|
|
229
|
+
}
|
|
230
|
+
const replacement = transformed.join("\n");
|
|
231
|
+
replaceRange(ta, start, end, replacement);
|
|
232
|
+
ta.setSelectionRange(start, start + replacement.length);
|
|
233
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inspect the textarea's current line and cursor position, return the
|
|
3
|
+
* set of markdown formats currently "active" at the caret. The toolbar
|
|
4
|
+
* uses this to highlight matching buttons (overtype convention: blue
|
|
5
|
+
* tint on the icon when the cursor sits inside a styled span).
|
|
6
|
+
*
|
|
7
|
+
* Format IDs match the toolbar button IDs: `bold`, `italic`, `code`,
|
|
8
|
+
* `h1`, `h2`, `h3`, `bullet`, `ordered`, `quote`.
|
|
9
|
+
*
|
|
10
|
+
* Inline detection is heuristic — we count delimiter occurrences on
|
|
11
|
+
* the current line up to the caret. An odd count means we're "inside"
|
|
12
|
+
* that delimiter. This works for typical markdown but won't catch
|
|
13
|
+
* every CommonMark edge case (escaped asterisks, intra-word
|
|
14
|
+
* underscores, etc.). Good enough for live UI feedback.
|
|
15
|
+
*/
|
|
16
|
+
/** True if the caret on this line sits inside an unclosed inline-code
|
|
17
|
+
* span (odd number of backticks before it). */
|
|
18
|
+
const inOpenCode = (beforeOnLine: string): boolean => (beforeOnLine.match(/`/g) ?? []).length % 2 !== 0;
|
|
19
|
+
|
|
20
|
+
/** True if the caret sits between `](` and `)` of a markdown link, i.e.
|
|
21
|
+
* inside the URL parens. We don't want to report bold/italic/code as
|
|
22
|
+
* active while editing a URL. */
|
|
23
|
+
const inLinkUrl = (beforeOnLine: string): boolean => {
|
|
24
|
+
// Scan from right-to-left for the first unbalanced "](" with no `)`
|
|
25
|
+
// between it and the caret.
|
|
26
|
+
const lastUrlOpen = beforeOnLine.lastIndexOf("](");
|
|
27
|
+
if (lastUrlOpen === -1) return false;
|
|
28
|
+
const afterOpen = beforeOnLine.slice(lastUrlOpen + 2);
|
|
29
|
+
return !afterOpen.includes(")");
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Replace ranges between paired delimiters with spaces of equal length
|
|
33
|
+
* so the remaining string preserves character offsets but no longer
|
|
34
|
+
* contains "active" delimiter chars. Used to neutralise inline code
|
|
35
|
+
* spans and link URLs before counting bold/italic delimiters. */
|
|
36
|
+
const scrub = (s: string): string =>
|
|
37
|
+
s
|
|
38
|
+
// Inline code: closed spans (paired backtick runs)
|
|
39
|
+
.replace(/(?<!`)(`+)(?!`)([^\n]+?)\1(?!`)/g, (m) => " ".repeat(m.length))
|
|
40
|
+
// Link URL section `](…)` — keep `[label` intact so its asterisks
|
|
41
|
+
// are still counted; only the URL is scrubbed.
|
|
42
|
+
.replace(/\]\(([^)\n]*?)\)/g, (m) => " ".repeat(m.length));
|
|
43
|
+
|
|
44
|
+
export const computeActiveFormats = (textarea: HTMLTextAreaElement): Set<string> => {
|
|
45
|
+
const value = textarea.value;
|
|
46
|
+
const caret = textarea.selectionStart;
|
|
47
|
+
const active = new Set<string>();
|
|
48
|
+
|
|
49
|
+
const lineStart = value.lastIndexOf("\n", caret - 1) + 1;
|
|
50
|
+
const nextNl = value.indexOf("\n", caret);
|
|
51
|
+
const lineEnd = nextNl === -1 ? value.length : nextNl;
|
|
52
|
+
const line = value.slice(lineStart, lineEnd);
|
|
53
|
+
const beforeOnLine = value.slice(lineStart, caret);
|
|
54
|
+
|
|
55
|
+
// Line-level — at most one block format per line.
|
|
56
|
+
const header = /^(#{1,3})\s/.exec(line);
|
|
57
|
+
if (header) active.add(`h${header[1]!.length}`);
|
|
58
|
+
else if (/^\s*[-*+]\s/.test(line)) active.add("bullet");
|
|
59
|
+
else if (/^\s*\d+\.\s/.test(line)) active.add("ordered");
|
|
60
|
+
else if (/^>\s/.test(line)) active.add("quote");
|
|
61
|
+
|
|
62
|
+
// Caret inside an OPEN inline-code span — only "code" is meaningful;
|
|
63
|
+
// everything else inside code is verbatim text, not active markdown.
|
|
64
|
+
if (inOpenCode(beforeOnLine)) {
|
|
65
|
+
active.add("code");
|
|
66
|
+
return active;
|
|
67
|
+
}
|
|
68
|
+
// Caret inside a link URL — nothing inline is "active". The user is
|
|
69
|
+
// editing the URL, not styled prose.
|
|
70
|
+
if (inLinkUrl(beforeOnLine)) return active;
|
|
71
|
+
|
|
72
|
+
// Scrub closed code spans + link URLs so we don't count their inner
|
|
73
|
+
// asterisks/underscores as styling delimiters on this line.
|
|
74
|
+
const scrubbed = scrub(beforeOnLine);
|
|
75
|
+
|
|
76
|
+
// Bold (`**`): count occurrences; strip then count single asterisks
|
|
77
|
+
// for italic, so `**bold**` doesn't double-count its inner stars.
|
|
78
|
+
const boldPairs = (scrubbed.match(/\*\*/g) ?? []).length;
|
|
79
|
+
if (boldPairs % 2 !== 0) active.add("bold");
|
|
80
|
+
|
|
81
|
+
const scrubbedNoBold = scrubbed.replace(/\*\*/g, "");
|
|
82
|
+
const italicStars = (scrubbedNoBold.match(/\*/g) ?? []).length;
|
|
83
|
+
if (italicStars % 2 !== 0) active.add("italic");
|
|
84
|
+
|
|
85
|
+
// Underscore-bold (`__`) and underscore-italic (`_…_`). Mirror the
|
|
86
|
+
// highlighter so the toolbar reports active state for both delimiter
|
|
87
|
+
// styles. We strip `__` first to disambiguate single vs double, same
|
|
88
|
+
// as the asterisk branch.
|
|
89
|
+
if ((scrubbed.match(/__/g) ?? []).length % 2 !== 0) active.add("bold");
|
|
90
|
+
const scrubbedNoDouble = scrubbed.replace(/__/g, "");
|
|
91
|
+
if ((scrubbedNoDouble.match(/_/g) ?? []).length % 2 !== 0) active.add("italic");
|
|
92
|
+
|
|
93
|
+
return active;
|
|
94
|
+
};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editor input behaviours — pure event handlers that translate user
|
|
3
|
+
* gestures (keystrokes, paste) into the textarea-manipulation actions
|
|
4
|
+
* defined in `actions.ts`. None of these own state of their own; each
|
|
5
|
+
* is a small function the host component wires into the matching DOM
|
|
6
|
+
* event.
|
|
7
|
+
*
|
|
8
|
+
* Conventions:
|
|
9
|
+
*
|
|
10
|
+
* - Each handler returns `true` when it has consumed the event;
|
|
11
|
+
* the caller should then `preventDefault()` and stop further
|
|
12
|
+
* processing. `false` means "I'm not interested — fall through to
|
|
13
|
+
* the next handler or to the browser default".
|
|
14
|
+
* - All mutations go through `actions.ts` or `execCommand("insertText")`
|
|
15
|
+
* so the textarea's native undo history stays usable. We never
|
|
16
|
+
* assign `textarea.value` directly.
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
toggleBold,
|
|
20
|
+
toggleItalic,
|
|
21
|
+
toggleCode,
|
|
22
|
+
toggleBulletList,
|
|
23
|
+
toggleNumberedList,
|
|
24
|
+
toggleHeading,
|
|
25
|
+
insertLink,
|
|
26
|
+
} from "./actions";
|
|
27
|
+
|
|
28
|
+
/* ────────────────────────────────────────────────────────────────────
|
|
29
|
+
* Keyboard shortcuts
|
|
30
|
+
* ──────────────────────────────────────────────────────────────────── */
|
|
31
|
+
|
|
32
|
+
const isMac = (): boolean => typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Cmd/Ctrl + (B/I/E/K) + (Shift+1/2/3 for headings, Shift+7/8 for
|
|
36
|
+
* lists). Mac-aware (metaKey vs ctrlKey). Skips firing during IME
|
|
37
|
+
* composition (overtype issue #80 lesson). Layout-independent digit
|
|
38
|
+
* detection via `e.code === "DigitN"` so Shift+1 works on QWERTY,
|
|
39
|
+
* AZERTY, German, etc.
|
|
40
|
+
*/
|
|
41
|
+
export const handleShortcut = (e: KeyboardEvent, ta: HTMLTextAreaElement): boolean => {
|
|
42
|
+
if (e.isComposing) return false;
|
|
43
|
+
// AltGr on Windows / Linux raises `altKey + ctrlKey` together to
|
|
44
|
+
// produce keys like `@`. Skip Ctrl-shortcuts when Alt is held so we
|
|
45
|
+
// don't hijack AltGr character entry.
|
|
46
|
+
if (e.altKey) return false;
|
|
47
|
+
const mod = isMac() ? e.metaKey : e.ctrlKey;
|
|
48
|
+
if (!mod) return false;
|
|
49
|
+
|
|
50
|
+
if (!e.shiftKey) {
|
|
51
|
+
switch (e.key.toLowerCase()) {
|
|
52
|
+
case "b":
|
|
53
|
+
toggleBold(ta);
|
|
54
|
+
return true;
|
|
55
|
+
case "i":
|
|
56
|
+
toggleItalic(ta);
|
|
57
|
+
return true;
|
|
58
|
+
case "e":
|
|
59
|
+
// GitHub / Slack / Notion convention for inline code.
|
|
60
|
+
toggleCode(ta);
|
|
61
|
+
return true;
|
|
62
|
+
case "k":
|
|
63
|
+
insertLink(ta);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (e.shiftKey) {
|
|
69
|
+
// We check `e.code` (layout-independent physical key) rather than
|
|
70
|
+
// `e.key` (the produced character), so US Shift+1 (`!`),
|
|
71
|
+
// German Shift+1 (`!`), and AZERTY Shift+1 (`1`) all map to H1.
|
|
72
|
+
switch (e.code) {
|
|
73
|
+
case "Digit1":
|
|
74
|
+
toggleHeading(ta, 1);
|
|
75
|
+
return true;
|
|
76
|
+
case "Digit2":
|
|
77
|
+
toggleHeading(ta, 2);
|
|
78
|
+
return true;
|
|
79
|
+
case "Digit3":
|
|
80
|
+
toggleHeading(ta, 3);
|
|
81
|
+
return true;
|
|
82
|
+
case "Digit7":
|
|
83
|
+
toggleNumberedList(ta);
|
|
84
|
+
return true;
|
|
85
|
+
case "Digit8":
|
|
86
|
+
toggleBulletList(ta);
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return false;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/* ────────────────────────────────────────────────────────────────────
|
|
95
|
+
* Smart list continuation on Enter
|
|
96
|
+
* ──────────────────────────────────────────────────────────────────── */
|
|
97
|
+
|
|
98
|
+
// `(\s*)` indent, marker (`-`, `*`, `+`, or `N.`), required whitespace,
|
|
99
|
+
// then the rest of the line content.
|
|
100
|
+
const LIST_RE = /^(\s*)([-*+]|\d+\.)(\s+)(.*)$/;
|
|
101
|
+
|
|
102
|
+
const insertViaExecCommand = (ta: HTMLTextAreaElement, start: number, end: number, replacement: string): void => {
|
|
103
|
+
ta.focus();
|
|
104
|
+
ta.setSelectionRange(start, end);
|
|
105
|
+
document.execCommand("insertText", false, replacement);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Enter inside a list item:
|
|
110
|
+
* - Non-empty item + caret at-or-after the marker → insert a new
|
|
111
|
+
* line with the same indent + marker (numbered lists bump by 1).
|
|
112
|
+
* - Empty item (marker only) → strip the marker, exit the list.
|
|
113
|
+
* - Caret BEFORE the marker (e.g. column 0) → defer to native Enter
|
|
114
|
+
* so we don't produce `\n- - item` nonsense.
|
|
115
|
+
*/
|
|
116
|
+
export const handleListContinuation = (ta: HTMLTextAreaElement): boolean => {
|
|
117
|
+
const value = ta.value;
|
|
118
|
+
const caret = ta.selectionStart;
|
|
119
|
+
if (caret !== ta.selectionEnd) return false; // selection → defer to native
|
|
120
|
+
|
|
121
|
+
const lineStart = value.lastIndexOf("\n", caret - 1) + 1;
|
|
122
|
+
const nextNl = value.indexOf("\n", caret);
|
|
123
|
+
const lineEnd = nextNl === -1 ? value.length : nextNl;
|
|
124
|
+
const line = value.slice(lineStart, lineEnd);
|
|
125
|
+
|
|
126
|
+
const m = LIST_RE.exec(line);
|
|
127
|
+
if (!m) return false;
|
|
128
|
+
|
|
129
|
+
const [, indent, marker, spaces, content] = m;
|
|
130
|
+
const markerEndPos = lineStart + indent!.length + marker!.length + spaces!.length;
|
|
131
|
+
|
|
132
|
+
// Caret must be at or past the marker. Editing the indent itself
|
|
133
|
+
// (caret < markerEndPos) gets native Enter.
|
|
134
|
+
if (caret < markerEndPos) return false;
|
|
135
|
+
|
|
136
|
+
// Empty item → exit the list by clearing this line.
|
|
137
|
+
if (content!.trim() === "") {
|
|
138
|
+
insertViaExecCommand(ta, lineStart, lineEnd, "");
|
|
139
|
+
ta.dispatchEvent(new Event("input", { bubbles: true }));
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Non-empty item → continue with same indent + (renumbered) marker.
|
|
144
|
+
const numbered = /^(\d+)\.$/.exec(marker!);
|
|
145
|
+
const nextMarker = numbered ? `${parseInt(numbered[1]!, 10) + 1}.` : marker!;
|
|
146
|
+
const insert = `\n${indent}${nextMarker}${spaces}`;
|
|
147
|
+
insertViaExecCommand(ta, caret, caret, insert);
|
|
148
|
+
ta.dispatchEvent(new Event("input", { bubbles: true }));
|
|
149
|
+
return true;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/* ────────────────────────────────────────────────────────────────────
|
|
153
|
+
* Smart paste — URL on selection becomes a markdown link
|
|
154
|
+
* ──────────────────────────────────────────────────────────────────── */
|
|
155
|
+
|
|
156
|
+
const URL_RE = /^https?:\/\/\S+$/;
|
|
157
|
+
|
|
158
|
+
/** Markdown link destinations end at the first unescaped `)`. URLs in
|
|
159
|
+
* the wild often contain literal parens (Wikipedia disambiguation
|
|
160
|
+
* pages, Microsoft Docs, etc.). Percent-encode them before insertion
|
|
161
|
+
* so the rendered link syntax stays unambiguous. */
|
|
162
|
+
const escapeUrlForMarkdown = (url: string): string => url.replace(/\(/g, "%28").replace(/\)/g, "%29");
|
|
163
|
+
|
|
164
|
+
/** Reject strings that the loose regex accepts but `new URL()` can't
|
|
165
|
+
* parse (typos, mismatched-paren pastes, etc.). */
|
|
166
|
+
const isValidUrl = (raw: string): boolean => {
|
|
167
|
+
try {
|
|
168
|
+
new URL(raw);
|
|
169
|
+
return true;
|
|
170
|
+
} catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* If the clipboard contains a single URL AND there's a non-empty
|
|
177
|
+
* selection, replace the selection with `[selection](escapedUrl)`.
|
|
178
|
+
* Otherwise, defer to the browser's native paste.
|
|
179
|
+
*/
|
|
180
|
+
export const handleSmartPaste = (e: ClipboardEvent, ta: HTMLTextAreaElement): boolean => {
|
|
181
|
+
const clip = e.clipboardData;
|
|
182
|
+
if (!clip) return false;
|
|
183
|
+
|
|
184
|
+
const text = clip.getData("text/plain");
|
|
185
|
+
if (!text) return false;
|
|
186
|
+
const trimmed = text.trim();
|
|
187
|
+
if (!URL_RE.test(trimmed)) return false;
|
|
188
|
+
if (!isValidUrl(trimmed)) return false;
|
|
189
|
+
if (ta.selectionStart === ta.selectionEnd) return false;
|
|
190
|
+
|
|
191
|
+
insertLink(ta, escapeUrlForMarkdown(trimmed));
|
|
192
|
+
return true;
|
|
193
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown-only: detect whether a position in the text falls inside
|
|
3
|
+
* an inline-code span (between backticks) or a fenced code block
|
|
4
|
+
* (between triple-backtick lines).
|
|
5
|
+
*
|
|
6
|
+
* Used by the MarkdownEditor to suppress completions and
|
|
7
|
+
* auto-expansion inside code regions — code content is supposed to
|
|
8
|
+
* be verbatim. Lives here (and not in the generic completion engine)
|
|
9
|
+
* because the rule is markdown-specific.
|
|
10
|
+
*/
|
|
11
|
+
export const isInCodeZone = (text: string, pos: number): boolean => {
|
|
12
|
+
const before = text.slice(0, pos);
|
|
13
|
+
|
|
14
|
+
// Fenced code block: count "```" at line starts. Odd count → open.
|
|
15
|
+
const fenceMatches = before.match(/^```/gm);
|
|
16
|
+
if (fenceMatches && fenceMatches.length % 2 !== 0) return true;
|
|
17
|
+
|
|
18
|
+
// Inline code: odd number of `` ` `` on the current line before pos.
|
|
19
|
+
const lineStart = before.lastIndexOf("\n") + 1;
|
|
20
|
+
const lineBefore = before.slice(lineStart);
|
|
21
|
+
const tickCount = (lineBefore.match(/`/g) || []).length;
|
|
22
|
+
return tickCount % 2 !== 0;
|
|
23
|
+
};
|