Added Word Count
This commit is contained in:
@@ -58,6 +58,7 @@ TypstDrive is completely self-hostable. We provide a Docker image that packages
|
||||
|
||||
- [Docker](https://docs.docker.com/get-docker/)
|
||||
- [Docker Compose](https://docs.docker.com/compose/install/)
|
||||
- [Tinymist](https://github.com/Myriad-Dreamin/tinymist) (Required if running the backend locally for Language Server features)
|
||||
|
||||
### Getting Started
|
||||
|
||||
@@ -94,9 +95,15 @@ git clone https://github.com/typst/typst.git typst
|
||||
2. Run the dev server: `npm run dev`
|
||||
|
||||
### Backend
|
||||
1. Start the local database: `docker-compose up -d db`
|
||||
2. Navigate to the `server/` directory.
|
||||
3. Build and run: `cargo run`
|
||||
1. Ensure you have the required dependencies installed (e.g., `libssl-dev` on Ubuntu: `sudo apt-get install libssl-dev`).
|
||||
2. Install the `tinymist` CLI and ensure it is in your system's PATH, as the backend relies on it for Language Server Protocol (LSP) functionality.
|
||||
(e.g., via `cargo binstall tinymist` or downloading from [releases](https://github.com/Myriad-Dreamin/tinymist/releases)). Example for Linux x64:
|
||||
```bash
|
||||
curl -L -o ~/.cargo/bin/tinymist https://github.com/Myriad-Dreamin/tinymist/releases/latest/download/tinymist-linux-x64 && chmod +x ~/.cargo/bin/tinymist
|
||||
```
|
||||
3. Start the local database: `docker-compose up -d db`
|
||||
4. Navigate to the `server/` directory.
|
||||
5. Build and run: `cargo run`
|
||||
|
||||
Note: The frontend expects the backend to be running on port 3000. During local development via Vite, API calls are proxied automatically.
|
||||
|
||||
|
||||
+82
-13
@@ -1,10 +1,54 @@
|
||||
use crate::world::MemoryWorld;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use typst::diag::{SourceDiagnostic, Warned};
|
||||
use typst::layout::{Frame, FrameItem};
|
||||
use typst_layout::PagedDocument;
|
||||
use typst_pdf::{pdf, PdfOptions};
|
||||
use typst_render::render;
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct DocumentStats {
|
||||
pub pages: usize,
|
||||
pub words: usize,
|
||||
pub characters: usize,
|
||||
pub characters_excluding_spaces: usize,
|
||||
}
|
||||
|
||||
fn extract_stats(doc: &PagedDocument) -> DocumentStats {
|
||||
let mut text = String::new();
|
||||
let pages = doc.pages().len();
|
||||
for page in doc.pages() {
|
||||
extract_frame_text(&page.frame, &mut text);
|
||||
}
|
||||
|
||||
let words = text.split_whitespace().count();
|
||||
let characters = text.chars().count();
|
||||
let characters_excluding_spaces = text.chars().filter(|c| !c.is_whitespace()).count();
|
||||
|
||||
DocumentStats {
|
||||
pages,
|
||||
words,
|
||||
characters,
|
||||
characters_excluding_spaces,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_frame_text(frame: &Frame, text: &mut String) {
|
||||
for (_, item) in frame.items() {
|
||||
match item {
|
||||
FrameItem::Text(text_item) => {
|
||||
text.push_str(&text_item.text);
|
||||
text.push(' ');
|
||||
}
|
||||
FrameItem::Group(group) => {
|
||||
extract_frame_text(&group.frame, text);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TypstCompiler;
|
||||
|
||||
impl TypstCompiler {
|
||||
@@ -16,30 +60,41 @@ impl TypstCompiler {
|
||||
&self,
|
||||
text: String,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
) -> Result<(Vec<String>, String), Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>> {
|
||||
) -> Result<
|
||||
(Vec<String>, String, DocumentStats),
|
||||
Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>,
|
||||
> {
|
||||
let world = MemoryWorld::new(text, files);
|
||||
match typst::compile::<PagedDocument>(&world) {
|
||||
Warned {
|
||||
output: Ok(doc),
|
||||
warnings: _,
|
||||
} => {
|
||||
let stats = extract_stats(&doc);
|
||||
let svgs = doc.pages().iter().map(typst_svg::svg).collect();
|
||||
let thumbnail = if let Some(page) = doc.pages().first() {
|
||||
typst_svg::svg(page)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
Ok((svgs, thumbnail))
|
||||
Ok((svgs, thumbnail, stats))
|
||||
}
|
||||
Warned {
|
||||
output: Err(errors),
|
||||
warnings: _,
|
||||
} => {
|
||||
use typst::World;
|
||||
let diag = errors.into_iter().map(|d| {
|
||||
let range = d.span.id().and_then(|id| world.source(id).ok()).and_then(|s| s.range(d.span));
|
||||
let diag = errors
|
||||
.into_iter()
|
||||
.map(|d| {
|
||||
let range = d
|
||||
.span
|
||||
.id()
|
||||
.and_then(|id| world.source(id).ok())
|
||||
.and_then(|s| s.range(d.span));
|
||||
(d, range)
|
||||
}).collect();
|
||||
})
|
||||
.collect();
|
||||
Err(diag)
|
||||
}
|
||||
}
|
||||
@@ -67,11 +122,18 @@ impl TypstCompiler {
|
||||
warnings: _,
|
||||
} => {
|
||||
use typst::World;
|
||||
Err(errors.into_iter().map(|d| {
|
||||
let range = d.span.id().and_then(|id| world.source(id).ok()).and_then(|s| s.range(d.span));
|
||||
Err(errors
|
||||
.into_iter()
|
||||
.map(|d| {
|
||||
let range = d
|
||||
.span
|
||||
.id()
|
||||
.and_then(|id| world.source(id).ok())
|
||||
.and_then(|s| s.range(d.span));
|
||||
(d, range)
|
||||
}).collect())
|
||||
},
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,11 +161,18 @@ impl TypstCompiler {
|
||||
warnings: _,
|
||||
} => {
|
||||
use typst::World;
|
||||
Err(errors.into_iter().map(|d| {
|
||||
let range = d.span.id().and_then(|id| world.source(id).ok()).and_then(|s| s.range(d.span));
|
||||
Err(errors
|
||||
.into_iter()
|
||||
.map(|d| {
|
||||
let range = d
|
||||
.span
|
||||
.id()
|
||||
.and_then(|id| world.source(id).ok())
|
||||
.and_then(|s| s.range(d.span));
|
||||
(d, range)
|
||||
}).collect())
|
||||
},
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ use sqlx::{Pool, Postgres};
|
||||
|
||||
pub async fn init_db() -> Pool<Postgres> {
|
||||
let db_url = std::env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "postgres://postgres:password@localhost:5432/typstdrive".to_string());
|
||||
.unwrap_or_else(|_| "postgres://postgres:password@192.168.1.214:5432/typstdrive".to_string());
|
||||
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
|
||||
@@ -54,10 +54,13 @@ pub struct CompileRequest {
|
||||
pub document_id: Option<String>,
|
||||
}
|
||||
|
||||
use crate::compiler::DocumentStats;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CompileResponse {
|
||||
pub svgs: Option<Vec<String>>,
|
||||
pub errors: Option<Vec<Diagnostic>>,
|
||||
pub stats: Option<DocumentStats>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -215,7 +218,7 @@ pub async fn compile_handler(
|
||||
|
||||
let compiler = state.compiler.lock().await;
|
||||
match compiler.compile_svg(payload.text, files_map) {
|
||||
Ok((svgs, thumbnail)) => {
|
||||
Ok((svgs, thumbnail, stats)) => {
|
||||
if let Some(doc_id) = &payload.document_id {
|
||||
if can_save_thumbnail {
|
||||
let _ = sqlx::query("UPDATE documents SET thumbnail_svg = $1 WHERE id = $2")
|
||||
@@ -229,6 +232,7 @@ pub async fn compile_handler(
|
||||
Json(CompileResponse {
|
||||
svgs: Some(svgs),
|
||||
errors: None,
|
||||
stats: Some(stats),
|
||||
})
|
||||
}
|
||||
Err(diags) => {
|
||||
@@ -244,6 +248,7 @@ pub async fn compile_handler(
|
||||
Json(CompileResponse {
|
||||
svgs: None,
|
||||
errors: Some(errors),
|
||||
stats: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -318,7 +323,7 @@ pub async fn export_handler(
|
||||
Err(_) => (StatusCode::BAD_REQUEST, "Compilation failed").into_response(),
|
||||
},
|
||||
"svg" => match compiler.compile_svg(payload.text, files_map.clone()) {
|
||||
Ok((svgs, _)) => {
|
||||
Ok((svgs, _, _)) => {
|
||||
|
||||
|
||||
let mut combined = String::new();
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import { connectionStatus, documentStatsStore } from '../ts/store';
|
||||
|
||||
let showStatsModal = $state(false);
|
||||
|
||||
function toggleModal() {
|
||||
showStatsModal = !showStatsModal;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="h-8 border-t border-[var(--theme-border)] bg-[var(--theme-bg)] flex items-center justify-between px-4 text-xs text-[var(--theme-text)] select-none z-[60] relative">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-1.5 font-medium {
|
||||
$connectionStatus === 'connected' ? 'text-emerald-600 dark:text-emerald-400' : 'text-amber-600 dark:text-amber-400'
|
||||
}">
|
||||
<div class="w-1.5 h-1.5 rounded-full {$connectionStatus === 'connected' ? 'bg-emerald-500 shadow-[0_0_4px_rgba(16,185,129,0.4)]' : 'bg-amber-500 animate-pulse'}"></div>
|
||||
{$connectionStatus === 'connected' ? 'Document synced' : 'Connecting...'}
|
||||
</div>
|
||||
|
||||
{#if $documentStatsStore}
|
||||
<button class="hover:bg-gray-100 dark:hover:bg-white/10 px-2 py-0.5 rounded transition-colors flex items-center gap-1 cursor-pointer" onclick={toggleModal} aria-label="Word count statistics">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="opacity-70"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/></svg>
|
||||
{$documentStatsStore.words} words
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showStatsModal}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fixed inset-0 bg-black/20 dark:bg-black/40 z-[100] flex items-center justify-center backdrop-blur-sm" onclick={toggleModal}>
|
||||
<div class="bg-[var(--theme-bg)] text-[var(--theme-text)] border border-[var(--theme-border)] rounded-xl shadow-xl w-80 overflow-hidden" onclick={e => e.stopPropagation()}>
|
||||
<div class="px-4 py-3 border-b border-[var(--theme-border)] flex items-center justify-between">
|
||||
<h3 class="font-semibold text-sm">Word count</h3>
|
||||
<button onclick={toggleModal} aria-label="Close" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-4 flex flex-col gap-3 text-sm">
|
||||
<div class="flex justify-between items-center pb-2 border-b border-[var(--theme-border)] border-dashed">
|
||||
<span class="opacity-70">Pages</span>
|
||||
<span class="font-medium">{$documentStatsStore?.pages || 0}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center pb-2 border-b border-[var(--theme-border)] border-dashed">
|
||||
<span class="opacity-70">Words</span>
|
||||
<span class="font-medium">{$documentStatsStore?.words || 0}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center pb-2 border-b border-[var(--theme-border)] border-dashed">
|
||||
<span class="opacity-70">Characters</span>
|
||||
<span class="font-medium">{$documentStatsStore?.characters || 0}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="opacity-70">Characters excluding spaces</span>
|
||||
<span class="font-medium">{$documentStatsStore?.characters_excluding_spaces || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { exportTypst } from '../ts/typst-api';
|
||||
import { text } from '../ts/yjs-setup';
|
||||
import { connectionStatus, connectedUsers, themeStore, darkModeStore, editorViewStore, documentZoomStore, commentsSidebarOpen, versionHistoryOpen, triggerLspReconnect } from '../ts/store';
|
||||
import { connectionStatus, connectedUsers, themeStore, darkModeStore, editorViewStore, documentZoomStore, commentsSidebarOpen, versionHistoryOpen, triggerLspReconnect, documentStatsStore } from '../ts/store';
|
||||
import { themes } from '../ts/themes';
|
||||
import { goto } from '$app/navigation';
|
||||
import ShareModal from './ShareModal.svelte';
|
||||
@@ -414,13 +414,7 @@
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
<div class="flex items-center gap-1.5 ml-2 px-2 py-0.5 rounded-full text-[11px] font-medium {
|
||||
$connectionStatus === 'connected' ? 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-900/20 dark:text-emerald-400 dark:border-emerald-800/30' :
|
||||
'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-800/30'
|
||||
} border">
|
||||
<div class="w-1.5 h-1.5 rounded-full {$connectionStatus === 'connected' ? 'bg-emerald-500 shadow-[0_0_4px_rgba(16,185,129,0.4)]' : 'bg-amber-500 animate-pulse'}"></div>
|
||||
{$connectionStatus === 'connected' ? 'Synced' : 'Connecting...'}
|
||||
</div>
|
||||
<!-- Sync status removed from here and moved to Footer -->
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export const commentsSidebarOpen = writable(false);
|
||||
export const versionHistoryOpen = writable(false);
|
||||
export const commentReference = writable('');
|
||||
export const editorErrors = writable<Diagnostic[]>([]);
|
||||
export const documentStatsStore = writable<{pages: number; words: number; characters: number; characters_excluding_spaces: number} | null>(null);
|
||||
export const triggerLspReconnect = writable(0);
|
||||
|
||||
export interface AwarenessUser {
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface Diagnostic {
|
||||
export interface CompileResponse {
|
||||
svgs: string[] | null;
|
||||
errors: Diagnostic[] | null;
|
||||
stats?: { pages: number; words: number; characters: number; characters_excluding_spaces: number };
|
||||
}
|
||||
|
||||
export async function compileTypst(text: string, document_id?: string): Promise<CompileResponse> {
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
import Editor from '$lib/components/Editor.svelte';
|
||||
import Preview from '$lib/components/Preview.svelte';
|
||||
import Toolbar from '$lib/components/Toolbar.svelte';
|
||||
import DocFooter from '$lib/components/DocFooter.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import { text, initYjs, cleanupYjs } from '$lib/ts/yjs-setup';
|
||||
import { compileTypst } from '$lib/ts/typst-api';
|
||||
import type { Diagnostic } from '$lib/ts/typst-api';
|
||||
import { page } from '$app/stores';
|
||||
import { commentsSidebarOpen, commentReference, editorViewStore, editorErrors } from '$lib/ts/store';
|
||||
import { commentsSidebarOpen, commentReference, editorViewStore, editorErrors, documentStatsStore } from '$lib/ts/store';
|
||||
|
||||
let svgs = $state<string[]>([]);
|
||||
let errors = $state<Diagnostic[]>([]);
|
||||
@@ -57,6 +58,9 @@
|
||||
const docId = $page.params.id;
|
||||
compileTypst(content, docId)
|
||||
.then((res) => {
|
||||
if (res.stats) {
|
||||
$documentStatsStore = res.stats;
|
||||
}
|
||||
if (res.svgs) {
|
||||
svgs = res.svgs;
|
||||
errors = [];
|
||||
@@ -133,6 +137,8 @@
|
||||
<ErrorBanner {errors} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<DocFooter />
|
||||
</div>
|
||||
|
||||
<!-- Custom Context Menu for Editor -->
|
||||
|
||||
Reference in New Issue
Block a user