@autumnsgrove/groveengine 0.6.4 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/index.d.ts +1 -2
- package/dist/auth/index.js +8 -4
- package/dist/auth/session.d.ts +14 -33
- package/dist/auth/session.js +5 -103
- package/dist/components/admin/FloatingToolbar.svelte +373 -0
- package/dist/components/admin/FloatingToolbar.svelte.d.ts +17 -0
- package/dist/components/admin/MarkdownEditor.svelte +26 -347
- package/dist/components/admin/MarkdownEditor.svelte.d.ts +1 -1
- package/dist/components/admin/composables/index.d.ts +0 -2
- package/dist/components/admin/composables/index.js +0 -2
- package/dist/components/custom/ContentWithGutter.svelte +22 -25
- package/dist/components/custom/MobileTOC.svelte +20 -13
- package/dist/components/quota/UpgradePrompt.svelte +1 -1
- package/dist/server/services/database.d.ts +138 -0
- package/dist/server/services/database.js +234 -0
- package/dist/server/services/index.d.ts +5 -1
- package/dist/server/services/index.js +24 -2
- package/dist/server/services/turnstile.d.ts +66 -0
- package/dist/server/services/turnstile.js +131 -0
- package/dist/server/services/users.d.ts +104 -0
- package/dist/server/services/users.js +158 -0
- package/dist/styles/README.md +50 -0
- package/dist/styles/vine-pattern.css +24 -0
- package/dist/types/turnstile.d.ts +42 -0
- package/dist/ui/components/forms/TurnstileWidget.svelte +111 -0
- package/dist/ui/components/forms/TurnstileWidget.svelte.d.ts +14 -0
- package/dist/ui/components/primitives/dialog/dialog-overlay.svelte +1 -1
- package/dist/ui/components/primitives/sheet/sheet-overlay.svelte +1 -1
- package/dist/ui/components/ui/Glass.svelte +158 -0
- package/dist/ui/components/ui/Glass.svelte.d.ts +52 -0
- package/dist/ui/components/ui/GlassButton.svelte +157 -0
- package/dist/ui/components/ui/GlassButton.svelte.d.ts +39 -0
- package/dist/ui/components/ui/GlassCard.svelte +160 -0
- package/dist/ui/components/ui/GlassCard.svelte.d.ts +39 -0
- package/dist/ui/components/ui/GlassConfirmDialog.svelte +208 -0
- package/dist/ui/components/ui/GlassConfirmDialog.svelte.d.ts +52 -0
- package/dist/ui/components/ui/GlassOverlay.svelte +93 -0
- package/dist/ui/components/ui/GlassOverlay.svelte.d.ts +33 -0
- package/dist/ui/components/ui/Logo.svelte +161 -23
- package/dist/ui/components/ui/Logo.svelte.d.ts +4 -10
- package/dist/ui/components/ui/index.d.ts +5 -0
- package/dist/ui/components/ui/index.js +6 -0
- package/dist/ui/styles/grove.css +136 -0
- package/dist/ui/tokens/fonts.d.ts +69 -0
- package/dist/ui/tokens/fonts.js +341 -0
- package/dist/ui/tokens/index.d.ts +6 -5
- package/dist/ui/tokens/index.js +7 -6
- package/dist/utils/gutter.d.ts +2 -8
- package/dist/utils/markdown.d.ts +1 -0
- package/dist/utils/markdown.js +32 -11
- package/package.json +1 -1
- package/static/robots.txt +520 -0
- package/dist/auth/jwt.d.ts +0 -20
- package/dist/auth/jwt.js +0 -123
- package/dist/components/admin/composables/useCommandPalette.svelte.d.ts +0 -87
- package/dist/components/admin/composables/useCommandPalette.svelte.js +0 -158
- package/dist/components/admin/composables/useSlashCommands.svelte.d.ts +0 -104
- package/dist/components/admin/composables/useSlashCommands.svelte.js +0 -215
package/dist/auth/index.d.ts
CHANGED
|
@@ -1,2 +1 @@
|
|
|
1
|
-
export
|
|
2
|
-
export * from "./session.js";
|
|
1
|
+
export { verifyTenantOwnership, getVerifiedTenantId } from "./session.js";
|
package/dist/auth/index.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Auth barrel export
|
|
3
|
+
*
|
|
4
|
+
* Note: Legacy JWT utilities have been removed.
|
|
5
|
+
* Session management is now handled by Heartwood SessionDO.
|
|
6
|
+
* Only tenant verification functions remain.
|
|
7
|
+
*/
|
|
3
8
|
|
|
4
|
-
export
|
|
5
|
-
export * from './session.js';
|
|
9
|
+
export { verifyTenantOwnership, getVerifiedTenantId } from './session.js';
|
package/dist/auth/session.d.ts
CHANGED
|
@@ -1,42 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Tenant Access Control Utilities
|
|
3
|
+
*
|
|
4
|
+
* Note: Legacy JWT session functions have been removed.
|
|
5
|
+
* Session management is now handled by Heartwood SessionDO.
|
|
6
|
+
* This file only contains tenant verification functions.
|
|
6
7
|
*/
|
|
7
|
-
export function createSession(user: User, secret: string): Promise<string>;
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
* @
|
|
11
|
-
* @param {string} secret - Session secret
|
|
12
|
-
* @returns {Promise<User|null>} - User data or null if invalid
|
|
9
|
+
* @typedef {Object} User
|
|
10
|
+
* @property {string} email
|
|
13
11
|
*/
|
|
14
|
-
export function verifySession(token: string, secret: string): Promise<User | null>;
|
|
15
12
|
/**
|
|
16
|
-
*
|
|
17
|
-
* @
|
|
18
|
-
* @
|
|
19
|
-
* @returns {string} - Cookie header value
|
|
13
|
+
* @typedef {Object} SessionError
|
|
14
|
+
* @property {string} message
|
|
15
|
+
* @property {number} status
|
|
20
16
|
*/
|
|
21
|
-
export function createSessionCookie(token: string, isProduction?: boolean): string;
|
|
22
17
|
/**
|
|
23
|
-
*
|
|
24
|
-
* @
|
|
18
|
+
* @typedef {Object} TenantRow
|
|
19
|
+
* @property {string} email
|
|
25
20
|
*/
|
|
26
|
-
export function clearSessionCookie(): string;
|
|
27
|
-
/**
|
|
28
|
-
* Parse session token from cookie header
|
|
29
|
-
* @param {string} cookieHeader - Cookie header value
|
|
30
|
-
* @returns {string|null} - Session token or null
|
|
31
|
-
*/
|
|
32
|
-
export function parseSessionCookie(cookieHeader: string): string | null;
|
|
33
|
-
/**
|
|
34
|
-
* Check if an email is in the allowed admin list
|
|
35
|
-
* @param {string} email - Email address to check
|
|
36
|
-
* @param {string} allowedList - Comma-separated list of allowed emails
|
|
37
|
-
* @returns {boolean} - Whether the user is allowed
|
|
38
|
-
*/
|
|
39
|
-
export function isAllowedAdmin(email: string, allowedList: string): boolean;
|
|
40
21
|
/**
|
|
41
22
|
* Verify that a user owns/has access to a tenant
|
|
42
23
|
* @param {import('@cloudflare/workers-types').D1Database} db - D1 database instance
|
|
@@ -44,7 +25,7 @@ export function isAllowedAdmin(email: string, allowedList: string): boolean;
|
|
|
44
25
|
* @param {string} userEmail - User's email address
|
|
45
26
|
* @returns {Promise<boolean>} - Whether the user owns the tenant
|
|
46
27
|
*/
|
|
47
|
-
export function verifyTenantOwnership(db:
|
|
28
|
+
export function verifyTenantOwnership(db: import("@cloudflare/workers-types").D1Database, tenantId: string | undefined | null, userEmail: string): Promise<boolean>;
|
|
48
29
|
/**
|
|
49
30
|
* Get tenant ID with ownership verification
|
|
50
31
|
* Throws 403 if user doesn't own the tenant
|
|
@@ -54,7 +35,7 @@ export function verifyTenantOwnership(db: any, tenantId: string | undefined | nu
|
|
|
54
35
|
* @returns {Promise<string>} - Verified tenant ID
|
|
55
36
|
* @throws {SessionError} - If unauthorized
|
|
56
37
|
*/
|
|
57
|
-
export function getVerifiedTenantId(db:
|
|
38
|
+
export function getVerifiedTenantId(db: import("@cloudflare/workers-types").D1Database, tenantId: string | undefined | null, user: User | null | undefined): Promise<string>;
|
|
58
39
|
export type User = {
|
|
59
40
|
email: string;
|
|
60
41
|
};
|
package/dist/auth/session.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Tenant Access Control Utilities
|
|
3
|
+
*
|
|
4
|
+
* Note: Legacy JWT session functions have been removed.
|
|
5
|
+
* Session management is now handled by Heartwood SessionDO.
|
|
6
|
+
* This file only contains tenant verification functions.
|
|
3
7
|
*/
|
|
4
8
|
|
|
5
|
-
import { signJwt, verifyJwt } from "./jwt.js";
|
|
6
|
-
|
|
7
9
|
/**
|
|
8
10
|
* @typedef {Object} User
|
|
9
11
|
* @property {string} email
|
|
@@ -20,106 +22,6 @@ import { signJwt, verifyJwt } from "./jwt.js";
|
|
|
20
22
|
* @property {string} email
|
|
21
23
|
*/
|
|
22
24
|
|
|
23
|
-
const SESSION_COOKIE_NAME = "session";
|
|
24
|
-
const SESSION_DURATION_SECONDS = 60 * 60 * 24 * 7; // 7 days
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Create a session token for a user
|
|
28
|
-
* @param {User} user - User data
|
|
29
|
-
* @param {string} secret - Session secret
|
|
30
|
-
* @returns {Promise<string>} - Signed JWT token
|
|
31
|
-
*/
|
|
32
|
-
export async function createSession(user, secret) {
|
|
33
|
-
const payload = {
|
|
34
|
-
sub: user.email,
|
|
35
|
-
email: user.email,
|
|
36
|
-
exp: Math.floor(Date.now() / 1000) + SESSION_DURATION_SECONDS,
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
return await signJwt(payload, secret);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Verify a session token and return user data
|
|
44
|
-
* @param {string} token - Session token
|
|
45
|
-
* @param {string} secret - Session secret
|
|
46
|
-
* @returns {Promise<User|null>} - User data or null if invalid
|
|
47
|
-
*/
|
|
48
|
-
export async function verifySession(token, secret) {
|
|
49
|
-
const payload = await verifyJwt(token, secret);
|
|
50
|
-
|
|
51
|
-
if (!payload || !payload.email) {
|
|
52
|
-
return null;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
return {
|
|
56
|
-
email: payload.email,
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Create Set-Cookie header value for session
|
|
62
|
-
* @param {string} token - Session token
|
|
63
|
-
* @param {boolean} isProduction - Whether in production (for secure flag)
|
|
64
|
-
* @returns {string} - Cookie header value
|
|
65
|
-
*/
|
|
66
|
-
export function createSessionCookie(token, isProduction = true) {
|
|
67
|
-
const parts = [
|
|
68
|
-
`${SESSION_COOKIE_NAME}=${token}`,
|
|
69
|
-
"Path=/",
|
|
70
|
-
`Max-Age=${SESSION_DURATION_SECONDS}`,
|
|
71
|
-
"HttpOnly",
|
|
72
|
-
"SameSite=Strict",
|
|
73
|
-
];
|
|
74
|
-
|
|
75
|
-
if (isProduction) {
|
|
76
|
-
parts.push("Secure");
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
return parts.join("; ");
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Create Set-Cookie header value to clear session
|
|
84
|
-
* @returns {string} - Cookie header value
|
|
85
|
-
*/
|
|
86
|
-
export function clearSessionCookie() {
|
|
87
|
-
return `${SESSION_COOKIE_NAME}=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict`;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Parse session token from cookie header
|
|
92
|
-
* @param {string} cookieHeader - Cookie header value
|
|
93
|
-
* @returns {string|null} - Session token or null
|
|
94
|
-
*/
|
|
95
|
-
export function parseSessionCookie(cookieHeader) {
|
|
96
|
-
if (!cookieHeader) {
|
|
97
|
-
return null;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** @type {Record<string, string>} */
|
|
101
|
-
const cookies = cookieHeader.split(";").reduce((/** @type {Record<string, string>} */ acc, /** @type {string} */ cookie) => {
|
|
102
|
-
const [key, value] = cookie.trim().split("=");
|
|
103
|
-
if (key && value) {
|
|
104
|
-
acc[key] = value;
|
|
105
|
-
}
|
|
106
|
-
return acc;
|
|
107
|
-
}, {});
|
|
108
|
-
|
|
109
|
-
return cookies[SESSION_COOKIE_NAME] || null;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Check if an email is in the allowed admin list
|
|
114
|
-
* @param {string} email - Email address to check
|
|
115
|
-
* @param {string} allowedList - Comma-separated list of allowed emails
|
|
116
|
-
* @returns {boolean} - Whether the user is allowed
|
|
117
|
-
*/
|
|
118
|
-
export function isAllowedAdmin(email, allowedList) {
|
|
119
|
-
const allowed = allowedList.split(",").map((e) => e.trim().toLowerCase());
|
|
120
|
-
return allowed.includes(email.toLowerCase());
|
|
121
|
-
}
|
|
122
|
-
|
|
123
25
|
/**
|
|
124
26
|
* Verify that a user owns/has access to a tenant
|
|
125
27
|
* @param {import('@cloudflare/workers-types').D1Database} db - D1 database instance
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { Bold, Italic, Link, Heading1, Heading2, Heading3, Code } from "lucide-svelte";
|
|
3
|
+
import { tick, onMount } from "svelte";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* FloatingToolbar - Medium-style floating toolbar for text formatting
|
|
7
|
+
* Appears above selected text with formatting options
|
|
8
|
+
*
|
|
9
|
+
* @security This component modifies raw markdown content but does NOT sanitize input.
|
|
10
|
+
* The parent component MUST sanitize all content before persisting to the database
|
|
11
|
+
* to prevent XSS attacks. Use sanitizeMarkdown() when converting to HTML for display.
|
|
12
|
+
* See: $lib/utils/sanitize.js
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// Props
|
|
16
|
+
/** @type {{ textareaRef?: HTMLTextAreaElement | null, content?: string, readonly?: boolean, onContentChange?: (content: string) => void }} */
|
|
17
|
+
let {
|
|
18
|
+
textareaRef = /** @type {HTMLTextAreaElement | null} */ (null),
|
|
19
|
+
content = $bindable(""),
|
|
20
|
+
readonly = false,
|
|
21
|
+
onContentChange = () => {},
|
|
22
|
+
} = $props();
|
|
23
|
+
|
|
24
|
+
// Toolbar state
|
|
25
|
+
let isVisible = $state(false);
|
|
26
|
+
let toolbarPosition = $state({ top: 0, left: 0 });
|
|
27
|
+
let selectionStart = $state(0);
|
|
28
|
+
let selectionEnd = $state(0);
|
|
29
|
+
|
|
30
|
+
/** @type {HTMLDivElement | null} */
|
|
31
|
+
let toolbarRef = $state(null);
|
|
32
|
+
|
|
33
|
+
// Track selection changes
|
|
34
|
+
function handleSelectionChange() {
|
|
35
|
+
if (!textareaRef || readonly) return;
|
|
36
|
+
|
|
37
|
+
const start = textareaRef.selectionStart;
|
|
38
|
+
const end = textareaRef.selectionEnd;
|
|
39
|
+
|
|
40
|
+
// Only show toolbar when there's actual selected text
|
|
41
|
+
if (start !== end && document.activeElement === textareaRef) {
|
|
42
|
+
selectionStart = start;
|
|
43
|
+
selectionEnd = end;
|
|
44
|
+
positionToolbar();
|
|
45
|
+
isVisible = true;
|
|
46
|
+
} else {
|
|
47
|
+
isVisible = false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function positionToolbar() {
|
|
52
|
+
if (!textareaRef || !toolbarRef) return;
|
|
53
|
+
|
|
54
|
+
// Get textarea bounding rect
|
|
55
|
+
const textareaRect = textareaRef.getBoundingClientRect();
|
|
56
|
+
|
|
57
|
+
// Calculate approximate position based on selection
|
|
58
|
+
// For textarea, we need to estimate position based on text metrics
|
|
59
|
+
const textBeforeSelection = content.substring(0, selectionStart);
|
|
60
|
+
const lines = textBeforeSelection.split('\n');
|
|
61
|
+
const currentLineIndex = lines.length - 1;
|
|
62
|
+
const lineHeight = parseFloat(getComputedStyle(textareaRef).lineHeight) || 24;
|
|
63
|
+
|
|
64
|
+
// Calculate vertical position (above the selection)
|
|
65
|
+
const scrollTop = textareaRef.scrollTop;
|
|
66
|
+
const lineTop = currentLineIndex * lineHeight - scrollTop;
|
|
67
|
+
const toolbarTop = textareaRect.top + lineTop - 48; // 48px gap above selection
|
|
68
|
+
|
|
69
|
+
// Calculate horizontal center
|
|
70
|
+
const toolbarWidth = toolbarRef?.offsetWidth || 200;
|
|
71
|
+
let toolbarLeft = textareaRect.left + (textareaRect.width / 2) - (toolbarWidth / 2);
|
|
72
|
+
|
|
73
|
+
// Viewport constraints
|
|
74
|
+
const viewportWidth = window.innerWidth;
|
|
75
|
+
const viewportHeight = window.innerHeight;
|
|
76
|
+
const padding = 12;
|
|
77
|
+
|
|
78
|
+
// Constrain to viewport
|
|
79
|
+
toolbarLeft = Math.max(padding, Math.min(toolbarLeft, viewportWidth - toolbarWidth - padding));
|
|
80
|
+
const finalTop = Math.max(padding, Math.min(toolbarTop, viewportHeight - 60));
|
|
81
|
+
|
|
82
|
+
toolbarPosition = {
|
|
83
|
+
top: finalTop,
|
|
84
|
+
left: toolbarLeft,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Wrap selected text with formatting markers
|
|
90
|
+
* @param {string} before
|
|
91
|
+
* @param {string} after
|
|
92
|
+
*/
|
|
93
|
+
async function wrapSelection(before, after) {
|
|
94
|
+
if (!textareaRef) return;
|
|
95
|
+
|
|
96
|
+
const selectedText = content.substring(selectionStart, selectionEnd);
|
|
97
|
+
const newContent =
|
|
98
|
+
content.substring(0, selectionStart) +
|
|
99
|
+
before + selectedText + after +
|
|
100
|
+
content.substring(selectionEnd);
|
|
101
|
+
|
|
102
|
+
content = newContent;
|
|
103
|
+
onContentChange(newContent);
|
|
104
|
+
|
|
105
|
+
await tick();
|
|
106
|
+
|
|
107
|
+
// Restore selection inside the wrapped text
|
|
108
|
+
textareaRef.selectionStart = selectionStart + before.length;
|
|
109
|
+
textareaRef.selectionEnd = selectionEnd + before.length;
|
|
110
|
+
textareaRef.focus();
|
|
111
|
+
|
|
112
|
+
isVisible = false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Insert text at beginning of selected line(s)
|
|
117
|
+
* @param {string} prefix
|
|
118
|
+
*/
|
|
119
|
+
async function insertLinePrefix(prefix) {
|
|
120
|
+
if (!textareaRef) return;
|
|
121
|
+
|
|
122
|
+
// Find the start of the current line
|
|
123
|
+
const beforeSelection = content.substring(0, selectionStart);
|
|
124
|
+
const lineStart = beforeSelection.lastIndexOf('\n') + 1;
|
|
125
|
+
|
|
126
|
+
const newContent =
|
|
127
|
+
content.substring(0, lineStart) +
|
|
128
|
+
prefix +
|
|
129
|
+
content.substring(lineStart);
|
|
130
|
+
|
|
131
|
+
content = newContent;
|
|
132
|
+
onContentChange(newContent);
|
|
133
|
+
|
|
134
|
+
await tick();
|
|
135
|
+
|
|
136
|
+
// Position cursor after the prefix
|
|
137
|
+
const newPos = selectionStart + prefix.length;
|
|
138
|
+
textareaRef.selectionStart = newPos;
|
|
139
|
+
textareaRef.selectionEnd = selectionEnd + prefix.length;
|
|
140
|
+
textareaRef.focus();
|
|
141
|
+
|
|
142
|
+
isVisible = false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function handleBold() {
|
|
146
|
+
wrapSelection("**", "**");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function handleItalic() {
|
|
150
|
+
wrapSelection("_", "_");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function handleCode() {
|
|
154
|
+
wrapSelection("`", "`");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function handleLink() {
|
|
158
|
+
wrapSelection("[", "](url)");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function handleH1() {
|
|
162
|
+
insertLinePrefix("# ");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function handleH2() {
|
|
166
|
+
insertLinePrefix("## ");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function handleH3() {
|
|
170
|
+
insertLinePrefix("### ");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function handleClickOutside(e) {
|
|
174
|
+
if (toolbarRef && !toolbarRef.contains(e.target) && e.target !== textareaRef) {
|
|
175
|
+
isVisible = false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Handle keyboard shortcuts for formatting
|
|
181
|
+
* @param {KeyboardEvent} e
|
|
182
|
+
*/
|
|
183
|
+
function handleKeyboardShortcuts(e) {
|
|
184
|
+
if (!textareaRef || readonly) return;
|
|
185
|
+
if (document.activeElement !== textareaRef) return;
|
|
186
|
+
|
|
187
|
+
const isMod = e.metaKey || e.ctrlKey;
|
|
188
|
+
if (!isMod) return;
|
|
189
|
+
|
|
190
|
+
// Update selection state before applying formatting
|
|
191
|
+
selectionStart = textareaRef.selectionStart;
|
|
192
|
+
selectionEnd = textareaRef.selectionEnd;
|
|
193
|
+
|
|
194
|
+
// Only apply if there's a selection
|
|
195
|
+
if (selectionStart === selectionEnd) return;
|
|
196
|
+
|
|
197
|
+
switch (e.key.toLowerCase()) {
|
|
198
|
+
case 'b':
|
|
199
|
+
e.preventDefault();
|
|
200
|
+
handleBold();
|
|
201
|
+
break;
|
|
202
|
+
case 'i':
|
|
203
|
+
e.preventDefault();
|
|
204
|
+
handleItalic();
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Set up selection monitoring and keyboard shortcuts
|
|
210
|
+
onMount(() => {
|
|
211
|
+
document.addEventListener("mouseup", handleSelectionChange);
|
|
212
|
+
document.addEventListener("keyup", handleSelectionChange);
|
|
213
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
214
|
+
document.addEventListener("keydown", handleKeyboardShortcuts);
|
|
215
|
+
|
|
216
|
+
return () => {
|
|
217
|
+
document.removeEventListener("mouseup", handleSelectionChange);
|
|
218
|
+
document.removeEventListener("keyup", handleSelectionChange);
|
|
219
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
220
|
+
document.removeEventListener("keydown", handleKeyboardShortcuts);
|
|
221
|
+
};
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Re-position when selection changes
|
|
225
|
+
$effect(() => {
|
|
226
|
+
if (isVisible && toolbarRef) {
|
|
227
|
+
positionToolbar();
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
</script>
|
|
231
|
+
|
|
232
|
+
{#if isVisible}
|
|
233
|
+
<div
|
|
234
|
+
bind:this={toolbarRef}
|
|
235
|
+
class="floating-toolbar"
|
|
236
|
+
style="top: {toolbarPosition.top}px; left: {toolbarPosition.left}px;"
|
|
237
|
+
role="toolbar"
|
|
238
|
+
aria-label="Text formatting toolbar"
|
|
239
|
+
>
|
|
240
|
+
<button
|
|
241
|
+
type="button"
|
|
242
|
+
class="toolbar-btn"
|
|
243
|
+
onclick={handleBold}
|
|
244
|
+
title="Bold (Cmd+B)"
|
|
245
|
+
aria-label="Bold"
|
|
246
|
+
>
|
|
247
|
+
<Bold size={16} />
|
|
248
|
+
</button>
|
|
249
|
+
|
|
250
|
+
<button
|
|
251
|
+
type="button"
|
|
252
|
+
class="toolbar-btn"
|
|
253
|
+
onclick={handleItalic}
|
|
254
|
+
title="Italic (Cmd+I)"
|
|
255
|
+
aria-label="Italic"
|
|
256
|
+
>
|
|
257
|
+
<Italic size={16} />
|
|
258
|
+
</button>
|
|
259
|
+
|
|
260
|
+
<button
|
|
261
|
+
type="button"
|
|
262
|
+
class="toolbar-btn"
|
|
263
|
+
onclick={handleCode}
|
|
264
|
+
title="Inline code"
|
|
265
|
+
aria-label="Code"
|
|
266
|
+
>
|
|
267
|
+
<Code size={16} />
|
|
268
|
+
</button>
|
|
269
|
+
|
|
270
|
+
<div class="toolbar-divider"></div>
|
|
271
|
+
|
|
272
|
+
<button
|
|
273
|
+
type="button"
|
|
274
|
+
class="toolbar-btn"
|
|
275
|
+
onclick={handleLink}
|
|
276
|
+
title="Insert link"
|
|
277
|
+
aria-label="Link"
|
|
278
|
+
>
|
|
279
|
+
<Link size={16} />
|
|
280
|
+
</button>
|
|
281
|
+
|
|
282
|
+
<div class="toolbar-divider"></div>
|
|
283
|
+
|
|
284
|
+
<button
|
|
285
|
+
type="button"
|
|
286
|
+
class="toolbar-btn"
|
|
287
|
+
onclick={handleH1}
|
|
288
|
+
title="Heading 1"
|
|
289
|
+
aria-label="Heading 1"
|
|
290
|
+
>
|
|
291
|
+
<Heading1 size={16} />
|
|
292
|
+
</button>
|
|
293
|
+
|
|
294
|
+
<button
|
|
295
|
+
type="button"
|
|
296
|
+
class="toolbar-btn"
|
|
297
|
+
onclick={handleH2}
|
|
298
|
+
title="Heading 2"
|
|
299
|
+
aria-label="Heading 2"
|
|
300
|
+
>
|
|
301
|
+
<Heading2 size={16} />
|
|
302
|
+
</button>
|
|
303
|
+
|
|
304
|
+
<button
|
|
305
|
+
type="button"
|
|
306
|
+
class="toolbar-btn"
|
|
307
|
+
onclick={handleH3}
|
|
308
|
+
title="Heading 3"
|
|
309
|
+
aria-label="Heading 3"
|
|
310
|
+
>
|
|
311
|
+
<Heading3 size={16} />
|
|
312
|
+
</button>
|
|
313
|
+
</div>
|
|
314
|
+
{/if}
|
|
315
|
+
|
|
316
|
+
<style>
|
|
317
|
+
.floating-toolbar {
|
|
318
|
+
position: fixed;
|
|
319
|
+
display: flex;
|
|
320
|
+
align-items: center;
|
|
321
|
+
gap: 0.25rem;
|
|
322
|
+
padding: 0.5rem 0.75rem;
|
|
323
|
+
background: rgba(30, 30, 30, 0.95);
|
|
324
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
325
|
+
border-radius: 9999px;
|
|
326
|
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05);
|
|
327
|
+
backdrop-filter: blur(8px);
|
|
328
|
+
z-index: 1000;
|
|
329
|
+
animation: toolbar-appear 0.15s ease-out;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
@keyframes toolbar-appear {
|
|
333
|
+
from {
|
|
334
|
+
opacity: 0;
|
|
335
|
+
transform: translateY(4px);
|
|
336
|
+
}
|
|
337
|
+
to {
|
|
338
|
+
opacity: 1;
|
|
339
|
+
transform: translateY(0);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
.toolbar-btn {
|
|
344
|
+
display: flex;
|
|
345
|
+
align-items: center;
|
|
346
|
+
justify-content: center;
|
|
347
|
+
width: 32px;
|
|
348
|
+
height: 32px;
|
|
349
|
+
padding: 0;
|
|
350
|
+
background: transparent;
|
|
351
|
+
border: none;
|
|
352
|
+
border-radius: 6px;
|
|
353
|
+
color: rgba(255, 255, 255, 0.7);
|
|
354
|
+
cursor: pointer;
|
|
355
|
+
transition: all 0.15s ease;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
.toolbar-btn:hover {
|
|
359
|
+
background: rgba(255, 255, 255, 0.1);
|
|
360
|
+
color: rgba(255, 255, 255, 0.95);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
.toolbar-btn:active {
|
|
364
|
+
transform: scale(0.95);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
.toolbar-divider {
|
|
368
|
+
width: 1px;
|
|
369
|
+
height: 20px;
|
|
370
|
+
background: rgba(255, 255, 255, 0.15);
|
|
371
|
+
margin: 0 0.25rem;
|
|
372
|
+
}
|
|
373
|
+
</style>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export default FloatingToolbar;
|
|
2
|
+
type FloatingToolbar = {
|
|
3
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
4
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
5
|
+
};
|
|
6
|
+
declare const FloatingToolbar: import("svelte").Component<{
|
|
7
|
+
textareaRef?: HTMLTextAreaElement | null;
|
|
8
|
+
content?: string;
|
|
9
|
+
readonly?: boolean;
|
|
10
|
+
onContentChange?: (content: string) => void;
|
|
11
|
+
}, {}, "content">;
|
|
12
|
+
type $$ComponentProps = {
|
|
13
|
+
textareaRef?: HTMLTextAreaElement | null;
|
|
14
|
+
content?: string;
|
|
15
|
+
readonly?: boolean;
|
|
16
|
+
onContentChange?: (content: string) => void;
|
|
17
|
+
};
|