diff --git a/.gitignore b/.gitignore index f1798bbc..78c7c665 100644 --- a/.gitignore +++ b/.gitignore @@ -16,19 +16,4 @@ mcp.json tasks/ -# --- C# / Visual Studio (UWP) --- -[Bb]in/ -[Oo]bj/ -.vs/ -*.user -*.suo -*.dbmdl -*.jfm -AppPackages/ -BundleArtifacts/ -*.appxupload -*.msixupload -*.appxbundle -*.msixbundle *.pfx -Package.StoreAssociation.xml diff --git a/AGENTS.md b/AGENTS.md index 0fa95104..807c8ded 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,7 +108,7 @@ src-tauri/src/ ### OBS 모드 (WebSocket 브릿지) - **이벤트 포워딩**: 새 Tauri 이벤트(`app.emit(...)`)를 추가할 때, OBS 오버레이에도 전달되어야 하면 `src-tauri/src/services/obs_bridge.rs`의 `register_event_forwarding()` 이벤트 목록에 등록 -- **deny 리스트**: OBS 클라이언트에서 실행 불가능한 커맨드는 `obs_bridge.rs`의 `DENIED_WS_COMMANDS`에 등록 (백엔드가 유일한 source of truth) +- **allowlist**: OBS 클라이언트에서 실행 가능한 커맨드만 `obs_bridge.rs`의 `ALLOWED_WS_COMMANDS`에 등록 (정확 일치, fail-closed — 목록에 없으면 차단, 백엔드가 유일한 source of truth). 신규 커맨드를 OBS에 노출하려면 검토 후 명시적으로 추가 - **IPC shim**: `src/renderer/api/ipcShim.ts`는 generic 설계 — 커맨드/이벤트별 분기 없음. 이벤트나 커맨드 추가 시 수정 불필요 ### 주석 @@ -130,6 +130,15 @@ src-tauri/src/ - `useMemo` / `useCallback` 의존성 배열에서 배열/객체는 개별 요소 비교 고려 - 린트 자동 수정이 의도적 패턴을 덮어쓸 수 있으므로 필요시 `eslint-disable` 주석 사용 +## store 자산·복구 안전 규칙 + +- **파일 자산 종류를 새로 추가할 때** (appData에 파일을 두고 store가 경로를 참조): `store.rs`의 orphan sweep 보호 집합(`collect_local_*_path_keys`)에 참조 수집을 추가하고, 크래시 직후·손상 복구 직후 시나리오와의 교차 테스트 필수 +- **sweep 불변식**: 자산 정리는 즉시 삭제가 아니라 `trash/<세션>/` 30일 격리 — 이를 우회하는 직접 `remove_file` 정리 경로 추가 금지. store 복구가 발생한 세션은 sweep이 자동 스킵됨(`skip_asset_sweep`) +- **store에 사용자 생성 컬렉션 필드를 추가할 때**: `migration.rs`의 `recover_collection_field`에 항목 단위 복구 등록 검토 (범용 헬퍼 재사용, 한 줄). 미등록 시 그 필드만 "손상 시 통째 초기화"로 폴백 +- **`keys[mode][i]` ↔ `keyPositions[mode][i]`는 인덱스 결합** — 복구·마이그레이션에서 배열 요소 제거 금지, 제자리 대체(`""` / default)만 허용 +- **편집 결합 컬렉션을 추가할 때**: 전용 세분 저장 커맨드를 새로 만들지 말고 `EditorDocumentV1` 필드와 `editor_commit` patch·검증·이벤트에 함께 추가 +- **editor_commit 오류 코드를 추가할 때**: 백엔드 오류 정의와 프론트 `EDITOR_ERROR_CODES`(`src/types/editor.ts`)에 반드시 함께 추가 — 프론트 목록에 없는 코드는 `retryable` 값과 무관하게 "이름표 없는 오류"로 취급되어 미저장 편집이 즉시 폐기됨 + ## API 문서 동기화 - 프론트엔드 플러그인 API(`dmn.*`) 또는 Tauri 커맨드에 변경이 있으면 `docs/content/` 하위 관련 MDX 문서를 업데이트 @@ -150,4 +159,3 @@ src-tauri/src/ 2. **린트**: `cd src-tauri && cargo clippy` 3. **포맷팅**: `cd src-tauri && cargo fmt` 4. **permissions 확인**: 커맨드 추가/삭제 시 빌드 후 `permissions/dmnote-allow-all.json` 자동 갱신 확인 - diff --git a/CLAUDE.md b/CLAUDE.md index 4156b55a..e54e6985 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,7 +108,7 @@ src-tauri/src/ ### OBS 모드 (WebSocket 브릿지) - **이벤트 포워딩**: 새 Tauri 이벤트(`app.emit(...)`)를 추가할 때, OBS 오버레이에도 전달되어야 하면 `src-tauri/src/services/obs_bridge.rs`의 `register_event_forwarding()` 이벤트 목록에 등록 -- **deny 리스트**: OBS 클라이언트에서 실행 불가능한 커맨드는 `obs_bridge.rs`의 `DENIED_WS_COMMANDS`에 등록 (백엔드가 유일한 source of truth) +- **allowlist**: OBS 클라이언트에서 실행 가능한 커맨드만 `obs_bridge.rs`의 `ALLOWED_WS_COMMANDS`에 등록 (정확 일치, fail-closed — 목록에 없으면 차단, 백엔드가 유일한 source of truth). 신규 커맨드를 OBS에 노출하려면 검토 후 명시적으로 추가 - **IPC shim**: `src/renderer/api/ipcShim.ts`는 generic 설계 — 커맨드/이벤트별 분기 없음. 이벤트나 커맨드 추가 시 수정 불필요 ### 주석 @@ -130,6 +130,15 @@ src-tauri/src/ - `useMemo` / `useCallback` 의존성 배열에서 배열/객체는 개별 요소 비교 고려 - 린트 자동 수정이 의도적 패턴을 덮어쓸 수 있으므로 필요시 `eslint-disable` 주석 사용 +## store 자산·복구 안전 규칙 + +- **파일 자산 종류를 새로 추가할 때** (appData에 파일을 두고 store가 경로를 참조): `store.rs`의 orphan sweep 보호 집합(`collect_local_*_path_keys`)에 참조 수집을 추가하고, 크래시 직후·손상 복구 직후 시나리오와의 교차 테스트 필수 +- **sweep 불변식**: 자산 정리는 즉시 삭제가 아니라 `trash/<세션>/` 30일 격리 — 이를 우회하는 직접 `remove_file` 정리 경로 추가 금지. store 복구가 발생한 세션은 sweep이 자동 스킵됨(`skip_asset_sweep`) +- **store에 사용자 생성 컬렉션 필드를 추가할 때**: `migration.rs`의 `recover_collection_field`에 항목 단위 복구 등록 검토 (범용 헬퍼 재사용, 한 줄). 미등록 시 그 필드만 "손상 시 통째 초기화"로 폴백 +- **`keys[mode][i]` ↔ `keyPositions[mode][i]`는 인덱스 결합** — 복구·마이그레이션에서 배열 요소 제거 금지, 제자리 대체(`""` / default)만 허용 +- **편집 결합 컬렉션을 추가할 때**: 전용 세분 저장 커맨드를 새로 만들지 말고 `EditorDocumentV1` 필드와 `editor_commit` patch·검증·이벤트에 함께 추가 +- **editor_commit 오류 코드를 추가할 때**: 백엔드 오류 정의와 프론트 `EDITOR_ERROR_CODES`(`src/types/editor.ts`)에 반드시 함께 추가 — 프론트 목록에 없는 코드는 `retryable` 값과 무관하게 "이름표 없는 오류"로 취급되어 미저장 편집이 즉시 폐기됨 + ## API 문서 동기화 - 프론트엔드 플러그인 API(`dmn.*`) 또는 Tauri 커맨드에 변경이 있으면 `docs/content/` 하위 관련 MDX 문서를 업데이트 diff --git a/GameBarOverlay/App.xaml b/GameBarOverlay/App.xaml deleted file mode 100644 index a0e62b51..00000000 --- a/GameBarOverlay/App.xaml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - diff --git a/GameBarOverlay/App.xaml.cs b/GameBarOverlay/App.xaml.cs deleted file mode 100644 index ff5520a2..00000000 --- a/GameBarOverlay/App.xaml.cs +++ /dev/null @@ -1,127 +0,0 @@ -using System; -using Windows.ApplicationModel; -using Windows.ApplicationModel.Activation; -using Windows.UI.Xaml; -using Windows.UI.Xaml.Controls; -using Windows.UI.Xaml.Navigation; -using Microsoft.Gaming.XboxGameBar; - -namespace GameBarOverlay -{ - public sealed partial class App : Application - { - private XboxGameBarWidget widget; - - public App() - { - Environment.SetEnvironmentVariable("WEBVIEW2_DEFAULT_BACKGROUND_COLOR", "00FFFFFF"); - InitializeComponent(); - Suspending += OnSuspending; - } - - protected override void OnLaunched(LaunchActivatedEventArgs e) - { - if (e.PrelaunchActivated) - { - return; - } - - var rootFrame = EnsureRootFrame(); - if (rootFrame.Content == null) - { - rootFrame.Navigate(typeof(MainPage)); - } - - Window.Current.Activate(); - } - - protected override void OnActivated(IActivatedEventArgs e) - { - XboxGameBarWidgetActivatedEventArgs widgetArgs = null; - if (e.Kind == ActivationKind.Protocol) - { - var protocolArgs = e as IProtocolActivatedEventArgs; - if ( - protocolArgs != null - && protocolArgs.Uri != null - && string.Equals( - protocolArgs.Uri.Scheme, - "ms-gamebarwidget", - StringComparison.OrdinalIgnoreCase - ) - ) - { - widgetArgs = e as XboxGameBarWidgetActivatedEventArgs; - } - } - - if (widgetArgs == null) - { - base.OnActivated(e); - return; - } - - NavigateToWidgetShell(widgetArgs); - } - - private void NavigateToWidgetShell(XboxGameBarWidgetActivatedEventArgs widgetArgs) - { - var rootFrame = EnsureRootFrame(); - if (widgetArgs.IsLaunchActivation || widget == null) - { - widget = new XboxGameBarWidget(widgetArgs, Window.Current.CoreWindow, rootFrame); - Window.Current.Closed -= OnWidgetWindowClosed; - Window.Current.Closed += OnWidgetWindowClosed; - } - - var page = rootFrame.Content as MainPage; - if (page == null) - { - rootFrame.Navigate(typeof(MainPage), widget); - } - else - { - page.AttachWidget(widget); - } - - page = rootFrame.Content as MainPage; - if (page != null) - { - page.HandleActivation(); - } - - Window.Current.Activate(); - } - - private Frame EnsureRootFrame() - { - if (Window.Current.Content is Frame rootFrame) - { - return rootFrame; - } - - rootFrame = new Frame(); - rootFrame.NavigationFailed += OnNavigationFailed; - Window.Current.Content = rootFrame; - return rootFrame; - } - - private void OnWidgetWindowClosed(object sender, Windows.UI.Core.CoreWindowEventArgs e) - { - widget = null; - Window.Current.Closed -= OnWidgetWindowClosed; - } - - private void OnNavigationFailed(object sender, NavigationFailedEventArgs e) - { - throw new Exception($"Failed to load page '{e.SourcePageType.FullName}'."); - } - - private void OnSuspending(object sender, SuspendingEventArgs e) - { - var deferral = e.SuspendingOperation.GetDeferral(); - widget = null; - deferral.Complete(); - } - } -} diff --git a/GameBarOverlay/Assets/LockScreenLogo.scale-200.png b/GameBarOverlay/Assets/LockScreenLogo.scale-200.png deleted file mode 100644 index 735f57ad..00000000 Binary files a/GameBarOverlay/Assets/LockScreenLogo.scale-200.png and /dev/null differ diff --git a/GameBarOverlay/Assets/SplashScreen.scale-200.png b/GameBarOverlay/Assets/SplashScreen.scale-200.png deleted file mode 100644 index 88bf4a7e..00000000 Binary files a/GameBarOverlay/Assets/SplashScreen.scale-200.png and /dev/null differ diff --git a/GameBarOverlay/Assets/Square150x150Logo.scale-200.png b/GameBarOverlay/Assets/Square150x150Logo.scale-200.png deleted file mode 100644 index 72555b3b..00000000 Binary files a/GameBarOverlay/Assets/Square150x150Logo.scale-200.png and /dev/null differ diff --git a/GameBarOverlay/Assets/Square44x44Logo.scale-200.png b/GameBarOverlay/Assets/Square44x44Logo.scale-200.png deleted file mode 100644 index 13c10472..00000000 Binary files a/GameBarOverlay/Assets/Square44x44Logo.scale-200.png and /dev/null differ diff --git a/GameBarOverlay/Assets/Square44x44Logo.targetsize-24_altform-unplated.png b/GameBarOverlay/Assets/Square44x44Logo.targetsize-24_altform-unplated.png deleted file mode 100644 index debc4b89..00000000 Binary files a/GameBarOverlay/Assets/Square44x44Logo.targetsize-24_altform-unplated.png and /dev/null differ diff --git a/GameBarOverlay/Assets/StoreLogo.png b/GameBarOverlay/Assets/StoreLogo.png deleted file mode 100644 index 5772d970..00000000 Binary files a/GameBarOverlay/Assets/StoreLogo.png and /dev/null differ diff --git a/GameBarOverlay/Assets/Wide310x150Logo.scale-200.png b/GameBarOverlay/Assets/Wide310x150Logo.scale-200.png deleted file mode 100644 index 73d756e5..00000000 Binary files a/GameBarOverlay/Assets/Wide310x150Logo.scale-200.png and /dev/null differ diff --git a/GameBarOverlay/GameBar/README.txt b/GameBarOverlay/GameBar/README.txt deleted file mode 100644 index 074630d2..00000000 --- a/GameBarOverlay/GameBar/README.txt +++ /dev/null @@ -1 +0,0 @@ -Game Bar public folder placeholder. diff --git a/GameBarOverlay/GameBarOverlay.csproj b/GameBarOverlay/GameBarOverlay.csproj deleted file mode 100644 index d807306e..00000000 --- a/GameBarOverlay/GameBarOverlay.csproj +++ /dev/null @@ -1,195 +0,0 @@ - - - - - - Debug - x86 - {591B01BF-71B2-4E7A-9AD2-97BF8EC4C490} - AppContainerExe - Properties - GameBarOverlay - GameBarOverlay - ko-KR - UAP - 10.0.26100.0 - 10.0.18362.0 - 14 - 512 - {A5A43C5B-DE2A-4C0C-9213-0A381AF9435A};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} - true - PackageReference - False - false - false - false - true - Always - x86|x64|arm64 - 0 - - - - true - bin\x86\Debug\ - DEBUG;TRACE;NETFX_CORE;WINDOWS_UWP - ;2008 - full - x86 - false - prompt - true - - - - bin\x86\Release\ - TRACE;NETFX_CORE;WINDOWS_UWP - true - ;2008 - pdbonly - x86 - false - prompt - true - true - - - - true - bin\x64\Debug\ - DEBUG;TRACE;NETFX_CORE;WINDOWS_UWP - ;2008 - full - x64 - false - prompt - - - - bin\x64\Release\ - TRACE;NETFX_CORE;WINDOWS_UWP - true - ;2008 - pdbonly - x64 - false - prompt - true - - - - true - bin\ARM64\Debug\ - DEBUG;TRACE;NETFX_CORE;WINDOWS_UWP - ;2008 - full - ARM64 - false - prompt - true - - - - bin\ARM64\Release\ - TRACE;NETFX_CORE;WINDOWS_UWP - true - ;2008 - pdbonly - ARM64 - false - prompt - true - true - - - - - App.xaml - - - MainPage.xaml - Code - - - - - - - MSBuild:Compile - Designer - - - MSBuild:Compile - Designer - - - - - - Designer - - - - - - - - - - - - - - - - - - - - - - $(NuGetPackageRoot)microsoft.gaming.xboxgamebar\7.3.2511061\lib\uap10.0\Microsoft.Gaming.XboxGameBar.winmd - true - - - $(NuGetPackageRoot)microsoft.ui.xaml\2.8.7\lib\uap10.0\Microsoft.UI.Xaml.winmd - true - - - $(NuGetPackageRoot)microsoft.web.webview2\1.0.2849.39\lib\Microsoft.Web.WebView2.Core.winmd - true - - - - - win32 - $(Platform) - - - - 18.0 - C:\Program Files (x86)\Microsoft SDKs\UWPNuGetPackages\microsoft.netcore.universalwindowsplatform\6.2.14\ref\uap10.0.15138 - $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\$(BaseIntermediateOutputPath)UwpReferenceAssemblies\')) - $(UwpReferenceAssemblyCacheRoot) - true - <_TargetFrameworkDirectories Condition="Exists('$(UwpReferenceAssemblySource)')">$(UwpReferenceAssemblyCacheRoot).NETCore\v5.0\ - <_FullFrameworkReferenceAssemblyPaths Condition="Exists('$(UwpReferenceAssemblySource)')">$(UwpReferenceAssemblyCacheRoot).NETCore\v5.0\ - $(UwpReferenceAssemblyCacheRoot).NETCore\v5.0\ - $(MSBuildExtensionsPath)\Microsoft\WindowsXaml\v$(VisualStudioVersion)\Microsoft.Windows.UI.Xaml.CSharp.targets - C:\Program Files\Microsoft Visual Studio\18\Enterprise\MSBuild\Microsoft\WindowsXaml\v18.0\Microsoft.Windows.UI.Xaml.CSharp.Targets - - - - - - - - <_UwpReferenceAssembly Include="$(UwpReferenceAssemblySource)\*.dll" /> - - - - - - - - diff --git a/GameBarOverlay/GameBarOverlay.slnx b/GameBarOverlay/GameBarOverlay.slnx deleted file mode 100644 index f42e857f..00000000 --- a/GameBarOverlay/GameBarOverlay.slnx +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/GameBarOverlay/MainPage.xaml b/GameBarOverlay/MainPage.xaml deleted file mode 100644 index 6ecfb0e1..00000000 --- a/GameBarOverlay/MainPage.xaml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - diff --git a/GameBarOverlay/MainPage.xaml.cs b/GameBarOverlay/MainPage.xaml.cs deleted file mode 100644 index a5523cc8..00000000 --- a/GameBarOverlay/MainPage.xaml.cs +++ /dev/null @@ -1,249 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Gaming.XboxGameBar; -using Microsoft.UI.Xaml.Controls; -using Microsoft.Web.WebView2.Core; -using Windows.Foundation; -using Windows.Data.Json; -using Windows.UI.Xaml; -using Windows.UI.Xaml.Controls; -using Windows.UI.Xaml.Navigation; -using Windows.Web.Http; - -namespace GameBarOverlay -{ - public sealed partial class MainPage : Page - { - private const ushort DefaultBridgePort = 34891; - private const int BridgePortScanCount = 10; - - private readonly DispatcherTimer reconnectTimer = new DispatcherTimer(); - private readonly HttpClient httpClient = new HttpClient(); - - private bool connected; - private bool isConnecting; - private bool disposed; - private XboxGameBarWidget widget; - - public MainPage() - { - InitializeComponent(); - - Loaded += OnLoaded; - Unloaded += OnUnloaded; - OverlayWebView.NavigationCompleted += OnNavigationCompleted; - - reconnectTimer.Interval = TimeSpan.FromSeconds(5); - reconnectTimer.Tick += OnReconnectTick; - } - - protected override void OnNavigatedTo(NavigationEventArgs e) - { - base.OnNavigatedTo(e); - AttachWidget(e.Parameter as XboxGameBarWidget); - } - - public void AttachWidget(XboxGameBarWidget gameBarWidget) - { - if (object.ReferenceEquals(widget, gameBarWidget)) - { - return; - } - - DetachWidget(); - widget = gameBarWidget; - if (widget == null) - { - ApplyWidgetState(); - return; - } - - widget.MinWindowSize = new Size(360, 220); - widget.MaxWindowSize = new Size(1920, 1080); - widget.PinningSupported = true; - widget.SettingsSupported = false; - widget.VerticalResizeSupported = true; - widget.RequestedThemeChanged += OnWidgetAppearanceChanged; - widget.RequestedOpacityChanged += OnWidgetAppearanceChanged; - widget.WindowStateChanged += OnWidgetWindowStateChanged; - ApplyWidgetState(); - } - - public void HandleActivation() - { - _ = EnsureBridgeConnectedAsync(); - } - - private async void OnLoaded(object sender, RoutedEventArgs e) - { - reconnectTimer.Start(); - await EnsureBridgeConnectedAsync(); - } - - private void OnUnloaded(object sender, RoutedEventArgs e) - { - if (disposed) - { - return; - } - - disposed = true; - reconnectTimer.Stop(); - OverlayWebView.NavigationCompleted -= OnNavigationCompleted; - OverlayWebView.Close(); - httpClient.Dispose(); - DetachWidget(); - } - - private async void OnReconnectTick(object sender, object e) - { - if (!connected) - { - await EnsureBridgeConnectedAsync(); - } - } - - private async Task EnsureBridgeConnectedAsync() - { - if (disposed || connected || isConnecting) - { - return; - } - - isConnecting = true; - connected = false; - StatusPanel.Visibility = Visibility.Visible; - StatusText.Text = "로컬 브리지를 탐색하는 중"; - - try - { - var bootstrap = await FindBootstrapAsync(); - if (bootstrap == null) - { - connected = false; - StatusText.Text = - "DmNote OBS 브리지를 찾지 못했습니다. Tauri 앱에서 OBS 모드를 먼저 시작하세요."; - return; - } - - await OverlayWebView.EnsureCoreWebView2Async(); - OverlayWebView.Source = new Uri(bootstrap.Url); - StatusText.Text = "브리지에 연결했습니다. 오버레이를 로드하는 중"; - } - catch (Exception ex) - { - connected = false; - StatusText.Text = $"브리지 연결 실패: {ex.Message}"; - } - finally - { - isConnecting = false; - } - } - - private void ApplyWidgetState() - { - if (widget == null) - { - RequestedTheme = ElementTheme.Default; - RootGrid.Opacity = 1.0; - return; - } - - RequestedTheme = widget.RequestedTheme; - RootGrid.Opacity = Math.Max(0.2, widget.RequestedOpacity); - } - - private void DetachWidget() - { - if (widget == null) - { - return; - } - - widget.RequestedThemeChanged -= OnWidgetAppearanceChanged; - widget.RequestedOpacityChanged -= OnWidgetAppearanceChanged; - widget.WindowStateChanged -= OnWidgetWindowStateChanged; - widget = null; - } - - private void OnWidgetAppearanceChanged(XboxGameBarWidget sender, object args) - { - ApplyWidgetState(); - } - - private void OnWidgetWindowStateChanged(XboxGameBarWidget sender, object args) - { - ApplyWidgetState(); - } - - private void OnNavigationCompleted(object sender, CoreWebView2NavigationCompletedEventArgs e) - { - connected = e.IsSuccess; - if (connected) - { - StatusPanel.Visibility = Visibility.Collapsed; - return; - } - - StatusPanel.Visibility = Visibility.Visible; - StatusText.Text = $"오버레이 로드 실패: {e.WebErrorStatus}"; - } - - private async Task FindBootstrapAsync() - { - for (var port = DefaultBridgePort; port < DefaultBridgePort + BridgePortScanCount; port++) - { - var bootstrap = await TryGetBootstrapAsync(port); - if (bootstrap != null) - { - return bootstrap; - } - } - - return null; - } - - private async Task TryGetBootstrapAsync(int port) - { - using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1))) - { - try - { - var response = await httpClient - .GetAsync(new Uri($"http://127.0.0.1:{port}/gamebar/bootstrap.json")) - .AsTask(cts.Token); - if (!response.IsSuccessStatusCode) - { - return null; - } - - var json = await response.Content.ReadAsStringAsync().AsTask(cts.Token); - var obj = JsonObject.Parse(json); - IJsonValue urlValue; - if (!obj.TryGetValue("url", out urlValue)) - { - return null; - } - - return new GameBarBootstrap(urlValue.GetString()); - } - catch - { - return null; - } - } - } - - private sealed class GameBarBootstrap - { - public GameBarBootstrap(string url) - { - Url = url; - } - - public string Url { get; } - } - } -} diff --git a/GameBarOverlay/Package.appxmanifest b/GameBarOverlay/Package.appxmanifest deleted file mode 100644 index 4e2a539f..00000000 --- a/GameBarOverlay/Package.appxmanifest +++ /dev/null @@ -1,124 +0,0 @@ - - - - - - - - - - GameBarOverlay - esihunc - Assets\StoreLogo.png - - - - - - - - - - - - - - - - - - - - - - true - true - false - false - - true - - 360 - 480 - 220 - 360 - 1080 - 1920 - - - true - true - - - - - - - - - - - - - - Microsoft.Gaming.XboxGameBar.winmd - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/GameBarOverlay/Properties/AssemblyInfo.cs b/GameBarOverlay/Properties/AssemblyInfo.cs deleted file mode 100644 index 9f66d963..00000000 --- a/GameBarOverlay/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -[assembly: AssemblyTitle("GameBarOverlay")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("GameBarOverlay")] -[assembly: AssemblyCopyright("Copyright © 2026")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] -[assembly: ComVisible(false)] diff --git a/GameBarOverlay/Properties/Default.rd.xml b/GameBarOverlay/Properties/Default.rd.xml deleted file mode 100644 index fa467633..00000000 --- a/GameBarOverlay/Properties/Default.rd.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/GameBarOverlay/Properties/PublishProfiles/win-arm64.pubxml b/GameBarOverlay/Properties/PublishProfiles/win-arm64.pubxml deleted file mode 100644 index 3481de2a..00000000 --- a/GameBarOverlay/Properties/PublishProfiles/win-arm64.pubxml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - FileSystem - ARM64 - win-arm64 - bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\ - true - - \ No newline at end of file diff --git a/GameBarOverlay/Properties/PublishProfiles/win-x64.pubxml b/GameBarOverlay/Properties/PublishProfiles/win-x64.pubxml deleted file mode 100644 index 4463ecc0..00000000 --- a/GameBarOverlay/Properties/PublishProfiles/win-x64.pubxml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - FileSystem - x64 - win-x64 - bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\ - true - - \ No newline at end of file diff --git a/GameBarOverlay/Properties/PublishProfiles/win-x86.pubxml b/GameBarOverlay/Properties/PublishProfiles/win-x86.pubxml deleted file mode 100644 index 31c51d68..00000000 --- a/GameBarOverlay/Properties/PublishProfiles/win-x86.pubxml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - FileSystem - x86 - win-x86 - bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\ - true - - \ No newline at end of file diff --git a/GameBarOverlay/Properties/launchSettings.json b/GameBarOverlay/Properties/launchSettings.json deleted file mode 100644 index aef8d0d3..00000000 --- a/GameBarOverlay/Properties/launchSettings.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "profiles": { - "GameBarOverlay": { - "commandName": "MsixPackage" - } - } -} \ No newline at end of file diff --git a/THIRD_PARTY_NOTICES.txt b/THIRD_PARTY_NOTICES.txt index 0980234e..c4ed537d 100644 --- a/THIRD_PARTY_NOTICES.txt +++ b/THIRD_PARTY_NOTICES.txt @@ -50,3 +50,127 @@ redistribution in binary form: LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +Pretendard +---------- + +DM Note bundles the Pretendard variable font +(src/renderer/assets/fonts/PretendardVariable.woff2), redistributed +unmodified under the SIL Open Font License, Version 1.1. + +Copyright (c) 2021, Kil Hyung-jin (https://github.com/orioncactus/pretendard), +with Reserved Font Name 'Pretendard'. + +Copyright 2014-2021 Adobe (http://www.adobe.com/), +with Reserved Font Name 'Source'. +Source is a trademark of Adobe in the United States and/or other countries. + +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter), +with Reserved Font Name 'Inter'. + +Copyright 2021 The M+ FONTS Project Authors (https://github.com/coz-m/MPLUS_FONTS), +with Reserved Font Name 'M PLUS 1'. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to any +document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may include +source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components +as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to +a new environment. + +"Author" refers to any designer, engineer, programmer, technical writer +or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining a +copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or in +the appropriate machine-readable metadata fields within text or binary +files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any Modified +Version, except to acknowledge the contribution(s) of the Copyright +Holder(s) and the Author(s) or with their explicit written permission. + +5) The Font Software, modified or unmodified, in part or in whole, must +be distributed entirely under this license, and must not be distributed +under any other license. The requirement for fonts to remain under this +license does not apply to any document created using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + + +apple_cursor +------------ + +DM Note incorporates modified vector path data from the apple_cursor +project by ful1e5 for its macOS resize cursors. + +Source: https://github.com/ful1e5/apple_cursor + +apple_cursor is licensed under the GNU General Public License, Version 3. +DM Note redistributes the modified cursor data under GPL-3.0-only, +consistent with DM Note's own license. The complete GNU GPL Version 3 +license text is included in the root LICENSE file. diff --git a/docs/content/en/advanced/page.mdx b/docs/content/en/advanced/page.mdx index b15e7256..b4218cbf 100644 --- a/docs/content/en/advanced/page.mdx +++ b/docs/content/en/advanced/page.mdx @@ -171,7 +171,7 @@ dmn.plugin.defineElement({ Only call `setState` when state actually changes: -```javascript +```javascript fragment=object-members onMount: ({ setState, getSettings }) => { let lastKps = 0; @@ -185,7 +185,7 @@ onMount: ({ setState, getSettings }) => { }, 100); return () => clearInterval(interval); -}; +}, ``` ## Multi-Window Patterns diff --git a/docs/content/en/api-reference/_meta.js b/docs/content/en/api-reference/_meta.js index 52467b30..b2e9d1a3 100644 --- a/docs/content/en/api-reference/_meta.js +++ b/docs/content/en/api-reference/_meta.js @@ -1,13 +1,15 @@ export default { - index: 'Overview', - app: 'App', - keys: 'Keys', - knobs: 'Knobs', - settings: 'Settings', - overlay: 'Overlay', - resources: 'Resources', - 'css-js': 'CSS/JS', - presets: 'Presets', - i18n: 'i18n', - plugin: 'Plugin', + index: "Overview", + app: "App", + keys: "Keys", + editor: "Editor", + knobs: "Knobs", + settings: "Settings", + overlay: "Overlay", + resources: "Resources", + "css-js": "CSS/JS", + sound: "Sound", + presets: "Presets", + i18n: "i18n", + plugin: "Plugin", }; diff --git a/docs/content/en/api-reference/app/page.mdx b/docs/content/en/api-reference/app/page.mdx index 05c2bf51..12b86221 100644 --- a/docs/content/en/api-reference/app/page.mdx +++ b/docs/content/en/api-reference/app/page.mdx @@ -9,6 +9,11 @@ description: dmn.app, dmn.window API reference App-level control including boot, restart, and external URLs. + + Automatic update is main-window only. Restart and quit wait for pending editor + writes in every native Tauri window before the process exits. + + ### `dmn.app.bootstrap(): Promise` Returns app bootstrap data (settings, keys, presets, etc.) @@ -20,9 +25,16 @@ const bootstrap = await dmn.app.bootstrap(); console.log(bootstrap.selectedKeyType); // 'keyboard' | 'mouse' console.log(bootstrap.settings); // app settings object console.log(bootstrap.keys.keyboard); // key mapping -console.log(bootstrap.keys.counter); // counter state +console.log(bootstrap.keyCounters); // counter state +console.log(bootstrap.keyCountersSessionId); // counter stream session +console.log(bootstrap.keyCountersRevision); // counter snapshot revision ``` +`keyCountersRevision` is a read-only, monotonically increasing watermark for +the returned `keyCounters` snapshot. It is intended for ordering bootstrap +snapshots against live counter updates within the same +`keyCountersSessionId`. A changed session ID starts a new revision sequence. + ### `dmn.app.restart(): Promise` Restarts the app. Settings are preserved. @@ -31,12 +43,12 @@ Restarts the app. Settings are preserved. await dmn.app.restart(); ``` -### `dmn.app.openExternalUrl(url: string): Promise` +### `dmn.app.openExternal(url: string): Promise` Opens a URL in the system default browser. ```javascript -await dmn.app.openExternalUrl("https://dmstudio.app"); +await dmn.app.openExternal('https://dmstudio.app'); ``` --- @@ -50,7 +62,7 @@ Window management API. Returns the current window type. ```typescript -type WindowType = "main" | "overlay"; +type WindowType = 'main' | 'overlay'; console.log(dmn.window.type); // 'main' or 'overlay' ``` @@ -86,13 +98,13 @@ await dmn.window.openDevtoolsAll(); ```javascript // App info display dmn.plugin.defineElement({ - name: "App Info", + name: 'App Info', maxInstances: 1, template: (state, settings, { html }) => html`
Window: ${dmn.window.type}
-
Mode: ${state.mode ?? "loading..."}
+
Mode: ${state.mode ?? 'loading...'}
`, diff --git a/docs/content/en/api-reference/css-js/page.mdx b/docs/content/en/api-reference/css-js/page.mdx index dc1fa6b7..312b42c3 100644 --- a/docs/content/en/api-reference/css-js/page.mdx +++ b/docs/content/en/api-reference/css-js/page.mdx @@ -9,101 +9,246 @@ Manage custom CSS and JavaScript plugins. ## dmn.css -### `dmn.css.get(): Promise` +Global custom CSS state is `{ path: string | null, content: string }`. -Returns current custom CSS code. +### `dmn.css.get(): Promise` + +Returns the current custom CSS (source file path and content). ```javascript -const css = await dmn.css.get(); -console.log(css); +const { path, content } = await dmn.css.get(); ``` -### `dmn.css.set(css: string): Promise` +### `dmn.css.getUse(): Promise` -Sets custom CSS code. +Returns whether custom CSS is enabled. -```javascript -await dmn.css.set(` - .key { - background: linear-gradient(45deg, #ff6b6b, #feca57); - border-radius: 8px; - } -`); +### `dmn.css.toggle(enabled: boolean): Promise<{ enabled: boolean }>` + +Enables or disables custom CSS. + +### `dmn.css.load(): Promise` + +Opens a file dialog and imports the selected CSS file. The file must be a +regular `.css` file, UTF-8 encoded, and at most 1 MiB. Successfully imported +files are added to the history (up to 10 entries). + +```typescript +type CssLoadResult = { + success: boolean; + error?: string; // error code on failure (see table below) + content?: string; + path?: string; +}; ``` -### `dmn.css.onChanged(callback): Unsubscribe` +If the user cancels the dialog, `success` is `false` and `error` is omitted. + +### `dmn.css.setContent(content: string): Promise` + +Replaces the CSS content directly (the source path is unchanged). Content is +limited to 1 MiB. + +### `dmn.css.reset(): Promise` -Subscribes to CSS changes. +Disables custom CSS and clears the content. The history is preserved. + +### `dmn.css.onUse(callback): Unsubscribe` + +Subscribes to enable/disable changes. The callback receives +`{ enabled: boolean }`. + +### `dmn.css.onContent(callback): Unsubscribe` + +Subscribes to content changes (imports, hot reload, direct edits). The +callback receives `CustomCss`. ```javascript -const unsub = dmn.css.onChanged((css) => { - console.log("CSS updated:", css); +const unsub = dmn.css.onContent(({ path, content }) => { + console.log("CSS updated:", path); }); ``` ---- +## dmn.css history -## dmn.js +The app keeps up to 10 previously imported CSS files. Only paths in this +history can be re-activated; arbitrary paths are rejected with +`PATH_NOT_AUTHORIZED`. + +### `dmn.css.historyGet(): Promise` -### `dmn.js.get(): Promise` +```typescript +type CustomCssHistoryItem = { + path: string; + lastUsedAt: number; // unix millis + status: "available" | "missing" | "invalid" | "tooLarge"; +}; +``` -Returns current JavaScript plugin code. +`status` is advisory: it reflects a quick file check at query time, and the +authoritative validation happens on activation. -```javascript -const js = await dmn.js.get(); -console.log(js); +### `dmn.css.historyActivate(path: string): Promise` + +Switches the global custom CSS to a history entry without a file dialog. + +```typescript +type CssActivateResult = { + success: boolean; + code?: CssHistoryErrorCode; // on failure + content?: string; + path?: string; +}; ``` -### `dmn.js.set(js: string): Promise` +### `dmn.css.historyRemove(path: string): Promise` -Sets JavaScript plugin code. +Removes an entry from the history (the file itself is not touched) and +returns the updated list. -```javascript -await dmn.js.set(` - // @id my-plugin - dmn.plugin.defineElement({ - name: "My Plugin", - template: (state, settings, { html }) => html\`
Hello
\`, - }); -`); +### Error codes + +| Code | Meaning | +| --- | --- | +| `PATH_NOT_AUTHORIZED` | The path is not in the history | +| `NOT_FOUND` | File does not exist | +| `NOT_REGULAR_FILE` | Not a regular file | +| `INVALID_EXTENSION` | Not a `.css` file | +| `TOO_LARGE` | Over 1 MiB | +| `INVALID_UTF8` | Not valid UTF-8 | +| `IO_ERROR` | Other I/O failure | + +## dmn.css.tab + +Per-tab CSS overrides the global CSS for a single key tab. A tab override is +`{ path: string | null, content: string, enabled: boolean }`. Tab CSS is +rendered only while the global custom CSS toggle is on. + +### `dmn.css.tab.getAll(): Promise` + +Returns all overrides as a `Record`. + +### `dmn.css.tab.get(tabId: string): Promise` + +Returns `{ tabId, css: TabCss | null }`. + +### `dmn.css.tab.load(tabId: string): Promise` + +Opens a file dialog and imports a CSS file for the tab. The same constraints +as `dmn.css.load()` apply; on failure `error` carries an error code string. + +### `dmn.css.tab.activateHistory(tabId: string, path: string): Promise` + +Applies a global history entry to the tab. The path must be in the history +(`PATH_NOT_AUTHORIZED` otherwise). + +```typescript +type TabCssActivateResult = { + success: boolean; + code?: CssHistoryErrorCode; + tabId: string; + css?: TabCss; +}; ``` -### `dmn.js.onChanged(callback): Unsubscribe` +### `dmn.css.tab.export(tabId: string): Promise` -Subscribes to JS changes. +Opens a save dialog and writes the tab's registered CSS content to a `.css` +file. -```javascript -const unsub = dmn.js.onChanged((js) => { - console.log("JS updated:", js); -}); +```typescript +type TabCssExportResult = { + success: boolean; + code?: "NO_TAB_CSS" | "IO_ERROR"; + error?: string; // details for IO_ERROR + path?: string; // saved location on success +}; ``` ---- +If the user cancels the dialog, `success` is `false` and `code` is omitted. -## Example Usage +### `dmn.css.tab.set(tabId: string, css: TabCss | null): Promise` + +Sets or removes (with `null`) the tab override directly. Only paths +previously authorized through a file dialog or history activation are +accepted: an unauthorized path is dropped and the override is stored as +content-only (`path: null` in the stored state and response). + +### `dmn.css.tab.toggle(tabId: string, enabled: boolean): Promise` + +Enables or disables the tab override without removing it. + +### `dmn.css.tab.clear(tabId: string): Promise` + +Removes the tab override, falling back to the global CSS. + +### `dmn.css.tab.onChanged(callback): Unsubscribe` + +Subscribes to tab override changes. The callback receives +`{ tabId, css: TabCss | null }`. + +## dmn.js + +JavaScript plugin state is `{ plugins: JsPlugin[] }` where each plugin is +`{ id, name, path, content, enabled }`. + +### `dmn.js.get(): Promise` + +Returns the current plugin list. + +### `dmn.js.getUse(): Promise` + +Returns whether JS plugins are enabled. + +### `dmn.js.toggle(enabled: boolean): Promise<{ enabled: boolean }>` + +Enables or disables JS plugins globally. + +### `dmn.js.load(): Promise` + +Opens a file dialog and adds the selected plugin files. + +```typescript +type JsLoadResult = { + success: boolean; + added: JsPlugin[]; + errors?: { path: string; error: string }[]; +}; +``` + +### `dmn.js.reload(): Promise` + +Re-reads all plugin files from disk and returns `{ updated, errors? }`. + +### `dmn.js.remove(id: string): Promise` + +Removes a plugin by id. + +### `dmn.js.setPluginEnabled(id: string, enabled: boolean): Promise` + +Enables or disables a single plugin. + +### `dmn.js.setContent(content: string): Promise` + +Replaces the inline JS content. + +### `dmn.js.reset(): Promise` + +Disables JS plugins and clears the plugin list. + +### `dmn.js.onUse(callback): Unsubscribe` + +Subscribes to enable/disable changes (`{ enabled: boolean }`). + +### `dmn.js.onState(callback): Unsubscribe` + +Subscribes to plugin list changes. The callback receives `CustomJs`. ```javascript -// CSS editor (main window) -if (dmn.window.type === "main") { - const cssEditor = document.createElement("textarea"); - cssEditor.style.cssText = - "width: 100%; height: 200px; font-family: monospace;"; - - // Load current CSS - dmn.css.get().then((css) => { - cssEditor.value = css; - }); - - // Watch changes - dmn.css.onChanged((css) => { - cssEditor.value = css; - }); - - // Save button - const saveBtn = document.createElement("button"); - saveBtn.textContent = "Save"; - saveBtn.onclick = () => { - dmn.css.set(cssEditor.value); - }; -} +const unsub = dmn.js.onState(({ plugins }) => { + console.log( + "plugins:", + plugins.map((p) => `${p.name}(${p.enabled ? "on" : "off"})`), + ); +}); ``` diff --git a/docs/content/en/api-reference/editor/page.mdx b/docs/content/en/api-reference/editor/page.mdx new file mode 100644 index 00000000..585e5ee5 --- /dev/null +++ b/docs/content/en/api-reference/editor/page.mdx @@ -0,0 +1,254 @@ +--- +title: Editor API +description: Atomic editor document reads, commits, and change events +--- + +# Editor API + + + OBS is read-only. Native Tauri main and overlay windows can write through the + revision coordinator; OBS can only read and subscribe to the canonical + document. + + +`dmn.editor` reads and updates the six collections that make up the editor +layout as one revisioned document. Use it when one user action changes more +than one collection, or when a consumer needs a single ordered change stream. + +## Document Types + +```typescript +type EditorField = + | 'keys' + | 'keyPositions' + | 'statPositions' + | 'graphPositions' + | 'knobPositions' + | 'layerGroups'; + +interface EditorDocumentV1 { + schemaVersion: 1; + keys: KeyMappings; + keyPositions: KeyPositions; + statPositions: StatItemPositions; + graphPositions: GraphItemPositions; + knobPositions: KnobItemPositions; + layerGroups: LayerGroups; +} + +type EditorPatchV1 = { + schemaVersion: 1; +} & Partial>; +``` + +Each field included in an `EditorPatchV1` is the complete canonical value of +that top-level collection. It is not an item-level diff. + +## Read the Current Document + +### `dmn.editor.get(): Promise` + +Returns the current revision and its matching full document in one snapshot. + +```typescript +interface EditorGetResult { + revision: number; + document: EditorDocumentV1; +} + +const { revision, document } = await dmn.editor.get(); +console.log(revision, document.keyPositions); +``` + +Keep the returned `revision`. A later commit uses it as `baseRevision` so that +an older snapshot cannot silently overwrite a newer edit. + +## Commit Changes Atomically + +### `dmn.editor.commit(request): Promise` + +Merges `changes` into the document at `baseRevision`, validates the completed +document, and persists all included collections in one atomic replacement. A +rejected commit does not partially apply its changes. DM Note requests the +strongest file and directory durability barriers available on each supported +platform, while no application can guarantee recovery from hardware or +firmware that violates those barriers. + + + The app's Undo/Redo path also restores the six editor collections, custom-tab + metadata, selected mode, counters, preset settings, and per-tab note settings + in one backend store transaction. This internal command is intentionally not + part of the public plugin API. + + +```typescript +interface EditorCommitRequest { + baseRevision: number; + mutationId: string; + gestureId?: string; + gestureIds?: string[]; + changes: EditorPatchV1; +} + +interface EditorCommitResult { + revision: number; + changedFields: EditorField[]; +} + +const snapshot = await dmn.editor.get(); +const result = await dmn.editor.commit({ + baseRevision: snapshot.revision, + mutationId: crypto.randomUUID(), + changes: { + schemaVersion: 1, + statPositions: nextStatPositions, + layerGroups: nextLayerGroups, + }, +}); + +console.log('Committed revision:', result.revision); +``` + +`mutationId` must be a UUID string no longer than 64 bytes. A recent request +retried with the same ID in the current app process is deduplicated. This is +intended for immediate IPC retries; the in-memory deduplication window does not +survive an app restart. Reusing a retained ID for a different request is +rejected. + +`gestureId` is the representative history gesture. `gestureIds` carries every +preview session coalesced into the request so the committed event can echo the +complete set. Both fields are optional and accept UUIDs only. The `gestureIds` +array can contain at most 32 entries, and the combined unique set across both +fields is also limited to 32 IDs. + +If the submitted values are already current, the result keeps the current +revision, returns an empty `changedFields`, and emits no canonical committed +event. Compatibility wrappers may still project their legacy per-field refresh +event once; retrying the same `mutationId` does not project it again. + +### Paired key structure changes + +`keys[mode][i]` and `keyPositions[mode][i]` describe the same item by index. +When an edit adds or removes a mode, or changes an array length, submit both +collections in the same commit. Their mode sets and lengths must match. + +```typescript +await dmn.editor.commit({ + baseRevision, + mutationId: crypto.randomUUID(), + changes: { + schemaVersion: 1, + keys: nextKeys, + keyPositions: nextKeyPositions, + }, +}); +``` + +A same-shape edit, such as changing a key label or a position property, may +update only its own collection. A shape-changing `keys`-only or +`keyPositions`-only request is rejected with `PAIRED_UPDATE_REQUIRED`. + +When using the compatibility `dmn.keys` API, call +`dmn.keys.updateWithPositions(keys, keyPositions)` for these structural +changes. Do not split them across `update()` and `updatePositions()`. + +## Commit Errors + +`commit()` rejects with this structured error. Branch on `errorCode`, not on +the human-readable `message`. + +```typescript +type EditorCommitErrorCode = + | 'REVISION_CONFLICT' + | 'VALIDATION_FAILED' + | 'TOO_MANY_GESTURE_IDS' + | 'INVALID_GESTURE_ID' + | 'PAIRED_UPDATE_REQUIRED' + | 'MUTATION_ID_REUSED' + | 'IO_ERROR'; + +interface EditorCommitError { + errorCode: EditorCommitErrorCode; + message: string; + details?: { + currentRevision?: number; + validationCode?: string; + field?: string; + }; + retryable: boolean; +} +``` + +| Code | Meaning | `retryable` | +| ------------------------ | ------------------------------------------------------------------------- | ----------- | +| `REVISION_CONFLICT` | `baseRevision` is stale; read, reconcile, and commit again | `true` | +| `VALIDATION_FAILED` | The completed document violates an editor validation rule | `false` | +| `TOO_MANY_GESTURE_IDS` | `gestureIds` exceeds 32 entries or the combined unique set exceeds 32 IDs | `false` | +| `INVALID_GESTURE_ID` | A gesture ID is not a UUID within the 64-byte limit | `false` | +| `PAIRED_UPDATE_REQUIRED` | A structural key change omitted its paired collection | `false` | +| `MUTATION_ID_REUSED` | The same mutation ID was used for a different request | `false` | +| `IO_ERROR` | The document could not be persisted | `true` | + +`details.currentRevision`, `details.validationCode`, or `details.field` is +included when it applies to the error. + +## Subscribe to Committed Changes + +### `dmn.editor.onCommitted(callback): ReadyUnsubscribe` + +Subscribes to the canonical `editor:committed` stream. One event represents +one successful atomic editor commit. + +```typescript +interface EditorCommittedV1 { + schemaVersion: 1; + revision: number; + mutationId: string; + gestureId?: string | null; + gestureIds?: string[]; + origin?: string; + changedFields: EditorField[]; + patch: EditorPatchV1; +} + +const unsubscribe = dmn.editor.onCommitted((event) => { + console.log(event.revision, event.changedFields); + applyEditorPatch(event.patch); +}); + +await unsubscribe.ready; + +dmn.plugin.registerCleanup(() => { + unsubscribe(); +}); +``` + +The returned unsubscribe function has a `ready: Promise` property. Await +it when no event may be missed before taking a snapshot. + +Events can arrive before the matching `commit()` promise resolves. Use +`mutationId` and `revision` to avoid applying the same change twice. If an +observed revision skips one or more values, call `dmn.editor.get()` and replace +the local snapshot. Ignore unknown `origin` values and future unknown fields. + +## Legacy Change Events + + + The per-collection events below remain available with their existing payloads + for plugin compatibility, but they are deprecated for new editor state + synchronization. Use `dmn.editor.onCommitted()` so one multi-collection edit + is observed as one ordered change. + + +| Compatibility event | Replacement | +| ------------------------------------- | -------------------------- | +| `dmn.keys.onChanged()` | `dmn.editor.onCommitted()` | +| `dmn.keys.onPositionsChanged()` | `dmn.editor.onCommitted()` | +| `dmn.statItems.onPositionsChanged()` | `dmn.editor.onCommitted()` | +| `dmn.graphItems.onPositionsChanged()` | `dmn.editor.onCommitted()` | +| `dmn.knobItems.onPositionsChanged()` | `dmn.editor.onCommitted()` | +| `dmn.layerGroups.onChanged()` | `dmn.editor.onCommitted()` | + +Existing plugins do not need to migrate immediately. New synchronization code +should subscribe to only the canonical event stream instead of applying both +the canonical and compatibility events. diff --git a/docs/content/en/api-reference/index/page.mdx b/docs/content/en/api-reference/index/page.mdx index 6e07854c..3d1c1cb6 100644 --- a/docs/content/en/api-reference/index/page.mdx +++ b/docs/content/en/api-reference/index/page.mdx @@ -15,12 +15,13 @@ All plugins access DM Note features through the `dmn` global object. | [`dmn.app`](/docs/api-reference/app) | App boot, restart, external URL | | [`dmn.window`](/docs/api-reference/app#window) | Window type, minimize, close | | [`dmn.keys`](/docs/api-reference/keys) | Key mapping, events, custom tabs | +| [`dmn.editor`](/docs/api-reference/editor) | Atomic editor state commits | | [`dmn.knobItems`](/docs/api-reference/knobs) | HID knob elements | | [`dmn.settings`](/docs/api-reference/settings) | App settings get/set | | [`dmn.overlay`](/docs/api-reference/overlay) | Overlay control | | [`dmn.font`](/docs/api-reference/resources#font) | Font file import | | [`dmn.image`](/docs/api-reference/resources#image) | Image file import | -| [`dmn.sound`](/docs/api-reference/resources#sound) | Sound file management | +| [`dmn.sound`](/docs/api-reference/sound) | Key sound library | | [`dmn.css`](/docs/api-reference/css-js#css) | CSS custom code | | [`dmn.js`](/docs/api-reference/css-js#js) | JS plugin management | | [`dmn.presets`](/docs/api-reference/presets) | Preset save/load | diff --git a/docs/content/en/api-reference/keys/page.mdx b/docs/content/en/api-reference/keys/page.mdx index 6b1478f6..8b2d754e 100644 --- a/docs/content/en/api-reference/keys/page.mdx +++ b/docs/content/en/api-reference/keys/page.mdx @@ -7,9 +7,14 @@ description: dmn.keys API reference for key events and mappings Key event subscription, mapping, and custom tab management. + + OBS is read-only. Native Tauri main and overlay windows can write through the + revision coordinator and subscribe to the same canonical events. + + ## Key State Events -### `dmn.keys.onKeyState(callback): Unsubscribe` +### `dmn.keys.onKeyState(callback): ReadyUnsubscribe` Overlay window only. @@ -18,11 +23,14 @@ Subscribes to state changes for registered keys. ```typescript interface KeyStateEvent { key: string; // e.g., 'KeyA', 'MouseLeft' - state: "UP" | "DOWN"; - mode: "keyboard" | "mouse"; + state: 'UP' | 'DOWN'; + mode: 'keyboard' | 'mouse'; // Elapsed time (ms) between input capture and event emit. // Recover the real input time with `performance.now() - eventAgeMs`. eventAgeMs?: number; + // UP events only. Physical hold duration (ms) measured by the input + // daemon at capture time - immune to delivery latency and jitter. + holdDurationMs?: number; } const unsub = dmn.keys.onKeyState(({ key, state, mode }) => { @@ -30,6 +38,33 @@ const unsub = dmn.keys.onKeyState(({ key, state, mode }) => { }); ``` +The returned function also exposes a `ready` promise that resolves once the +subscription is actually registered with the backend. Events fired before +`ready` resolves may not be delivered — await it when you need a guaranteed +starting point (e.g. before requesting a snapshot): + +```typescript +const unsub = dmn.keys.onKeyState(handler); +await unsub.ready; // subscription is live from this point +``` + +### `dmn.keys.onKeysReset(callback): ReadyUnsubscribe` + +Fires when the pressed-key state is invalidated as a whole, e.g. when the +keyboard hook (re)starts after a global shortcut change. Any state derived +from previous `onKeyState` events (held keys, active visualizations) should +be cleared and rebuilt from a fresh snapshot. + +```typescript +interface KeysResetEvent { + reason: string; // e.g., 'hook_restart' +} + +dmn.keys.onKeysReset(({ reason }) => { + console.log(`key state reset: ${reason}`); +}); +``` + ### `dmn.keys.onRawInput(callback): Unsubscribe` Overlay window only. @@ -38,10 +73,10 @@ Subscribes to all raw input events (including unregistered keys). ```typescript interface RawInputEvent { - device: "keyboard" | "mouse" | "gamepad" | "unknown"; + device: 'keyboard' | 'mouse' | 'gamepad' | 'unknown'; label: string; // e.g., 'A', 'LButton', 'HIDB:1ccf:101c:9:3' labels: string[]; // all candidate labels for this event - state: "DOWN" | "UP"; + state: 'DOWN' | 'UP'; } const unsub = dmn.keys.onRawInput(({ device, label, state }) => { @@ -65,7 +100,7 @@ Returns the current cumulative key counter snapshot. type KeyCounters = Record>; const counters = await dmn.keys.getCounters(); -console.log(counters["4key"]); +console.log(counters['4key']); ``` ### `dmn.keys.onCounterChanged(callback): Unsubscribe` @@ -76,15 +111,27 @@ Subscribes to counter changes. ```typescript interface CounterEvent { + mode: string; key: string; count: number; + sessionId: string; + revision: number; } -const unsub = dmn.keys.onCounterChanged(({ key, count }) => { - console.log(`${key}: ${count}`); -}); +const unsub = dmn.keys.onCounterChanged( + ({ mode, key, count, sessionId, revision }) => { + console.log( + `[${mode}] ${key}: ${count} (${sessionId}, revision ${revision})`, + ); + }, +); ``` +`revision` is the monotonically increasing order of the runtime counter +change within `sessionId`. Compare both fields with +`keyCountersSessionId`/`keyCountersRevision` from `dmn.app.bootstrap()` when +reconciling a bootstrap snapshot with live events. + --- ## Mode Events @@ -95,11 +142,11 @@ Subscribes to input mode changes (keyboard/mouse). ```typescript interface ModeEvent { - mode: "keyboard" | "mouse"; + mode: 'keyboard' | 'mouse'; } const unsub = dmn.keys.onModeChanged(({ mode }) => { - console.log("Mode changed:", mode); + console.log('Mode changed:', mode); }); ``` @@ -107,34 +154,148 @@ const unsub = dmn.keys.onModeChanged(({ mode }) => { ## Key Mapping -### `dmn.keys.get(): Promise` +### `dmn.keys.get(): Promise` -Returns current key mapping and counter state. +Returns the key mappings for all modes. ```typescript -interface KeysState { - keyboard: KeyboardMapping; - mouse: MouseMapping; - counter: Record; +type KeyMappings = Record; + +const mappings = await dmn.keys.get(); +console.log(mappings['4key']); +``` + +### `dmn.keys.update(mappings: KeyMappings): Promise` + +Replaces the key mappings and returns the committed value. + +```typescript +const mappings = await dmn.keys.get(); +mappings['4key'][0] = 'KeyS'; +const committed = await dmn.keys.update(mappings); +``` + +### `dmn.keys.getPositions(): Promise` + +Returns the key positions for all modes. + +```typescript +const positions = await dmn.keys.getPositions(); +console.log(positions['4key']); +``` + +Style-related fields on each `KeyPosition` include solid colors and optional +gradient siblings: + +```typescript +interface KeyPosition { + // ...position, image, note, and counter fields... + backgroundColor?: string; + activeBackgroundColor?: string; + borderColor?: string; + activeBorderColor?: string; + borderWidth?: number; // px, defaults to 1 when unset — 0 disables the border + backgroundGradient?: GradientSpec | null; + activeBackgroundGradient?: GradientSpec | null; + borderGradient?: GradientSpec | null; + activeBorderGradient?: GradientSpec | null; + shadow?: ElementShadowSpec; + activeShadow?: ElementShadowSpec; } -const keys = await dmn.keys.get(); -console.log(keys.keyboard); -console.log(keys.counter); +interface KeyCounterSettings { + // ...placement, typography, stroke, and animation fields... + fill: { idle: string; active: string }; + fillIdleGradient?: GradientSpec | null; + fillActiveGradient?: GradientSpec | null; +} + +interface GradientSpec { + angle: number; // 0 (inclusive) to 360 (exclusive) — 360 normalizes to 0; CSS semantics (0 = up, clockwise) + stops: { color: string; pos: number }[]; // 2–8 stops, pos 0–1 ascending +} + +interface ElementShadowSpec { + enabled: boolean; + color: string; // alpha controls opacity + offsetX: number; // px, -100 to 100 + offsetY: number; // px, -100 to 100 + blur: number; // px, 0 to 100 +} ``` -### `dmn.keys.set(keys: Partial): Promise` +When a gradient field is present it takes priority over the matching solid +field, and the solid field is kept in sync with the first stop color on save. +To return to a solid color, set the gradient field to `null` and update the +solid field. -Updates key mapping. +The counter fill gradient fields likewise take priority over `counter.fill.idle` +and `counter.fill.active`. Counter stroke remains solid-only. -```javascript -await dmn.keys.set({ - keyboard: { - /* new mapping */ - }, +`shadow` and `activeShadow` control the idle and pressed shadows independently. +When omitted, the built-in shadow remains unchanged. Store a spec with +`enabled: false` to explicitly disable a shadow. + +### `dmn.keys.updatePositions(positions: KeyPositions): Promise` + +Replaces the key positions and returns the committed value. + +```typescript +const positions = await dmn.keys.getPositions(); +positions['4key'][0].dx = 100; +const committed = await dmn.keys.updatePositions(positions); +``` + + + `keys[mode][i]` and `keyPositions[mode][i]` are coupled by index. A standalone + `update()` or `updatePositions()` may fail with `PAIRED_UPDATE_REQUIRED` if it + changes a mode set or an array length. Use `updateWithPositions()` for key + additions, removals, and reordering. + + +### `dmn.keys.updateWithPositions(mappings, positions): Promise` + +Updates key mappings and their index-coupled positions in one atomic commit. +The two values must have matching mode sets and array lengths. The committed +values are returned, and both `keys:changed` and `positions:changed` are emitted +after the commit succeeds. + +```typescript +interface KeysWithPositionsResult { + keys: KeyMappings; + positions: KeyPositions; +} + +const result = await dmn.keys.updateWithPositions(mappings, positions); +``` + +### `dmn.keys.onChanged(callback): Unsubscribe` + +Subscribes to key mapping changes (`keys:changed`). + +```typescript +const unsubscribe = dmn.keys.onChanged((mappings) => { + console.log('key mappings changed', mappings); }); ``` +### `dmn.keys.onPositionsChanged(callback): Unsubscribe` + +Subscribes to key position changes (`positions:changed`). + +```typescript +const unsubscribe = dmn.keys.onPositionsChanged((positions) => { + console.log('key positions changed', positions); +}); +``` + + + These two per-collection events remain available for compatibility, but are + deprecated for new editor state synchronization. Use + [`dmn.editor.onCommitted()`](/docs/api-reference/editor#subscribe-to-committed-changes) + to observe atomic multi-collection edits. + + --- ## Custom Tab @@ -153,8 +314,8 @@ interface CustomTabOptions { } const unsub = dmn.keys.registerCustomTab({ - id: "my-tab", - label: "My Tab", + id: 'my-tab', + label: 'My Tab', content: (container) => { container.innerHTML = `
Custom tab content
`; return () => { @@ -166,12 +327,15 @@ const unsub = dmn.keys.registerCustomTab({ ### `dmn.keys.customTabs.restore(customTabs, selectedKeyType): Promise` -Restores custom tabs and selected mode atomically. Syncs backend state, overlay, and main window. +Restores the names/order of existing custom tabs and the selected mode +atomically. Every tab ID must already have paired `keys` and `keyPositions`; +use `create`, `delete`, and `updateWithPositions` for topology changes. Invalid +or orphaned tab metadata is rejected without changing stored data. ```javascript await dmn.keys.customTabs.restore( - [{ id: "custom-123", name: "My Keys", keys: ["KeyA", "KeyS"] }], - "custom-123", + [{ id: 'custom-123', name: 'My Keys' }], + 'custom-123', ); ``` @@ -238,7 +402,7 @@ dmn.stats.reset(); ```javascript // KPS counter using stats API dmn.plugin.defineElement({ - name: "KPS Counter", + name: 'KPS Counter', maxInstances: 1, template: (state, settings, { html }) => html` diff --git a/docs/content/en/api-reference/knobs/page.mdx b/docs/content/en/api-reference/knobs/page.mdx index 6d2eb93d..502ca037 100644 --- a/docs/content/en/api-reference/knobs/page.mdx +++ b/docs/content/en/api-reference/knobs/page.mdx @@ -5,6 +5,11 @@ description: dmn.knobItems API reference for HID knob elements # Knobs API + + OBS is read-only. Native Tauri main and overlay windows can write knob layout + changes through the revision coordinator. + + Manage knob elements — rotary visualization elements bound to HID device axes (e.g., rhythm game controller knobs). While HID device **buttons** are mapped and visualized exactly like keyboard keys, **axes** are visualized with @@ -49,11 +54,17 @@ Commonly used inherited styling fields: - `backgroundColor` / `activeBackgroundColor` — idle / turning fill color - `borderColor` / `activeBorderColor` — idle / turning border color -- `borderWidth` — border thickness in px (default 3) +- `borderWidth` — solid border thickness in px when explicitly greater than 0; an omitted width uses a 1 px fallback only for a gradient border, and 0 disables both border forms +- `backgroundGradient` / `activeBackgroundGradient` — background gradient (GradientSpec, takes priority over the solid color) +- `borderGradient` / `activeBorderGradient` — border gradient (GradientSpec) +- `shadow` / `activeShadow` — idle / turning shadow (ElementShadowSpec; color alpha controls opacity) - `borderRadius` — corner radius in px; when unset the knob is a circle - `inactiveImage` / `activeImage` — idle / turning custom image - `idleTransparent` / `activeTransparent` — transparent background toggles +`ElementShadowSpec` contains `enabled`, `color`, `offsetX`, `offsetY`, and +`blur`. Offsets accept -100–100 px and blur accepts 0–100 px. + While the physical knob is turning, the element switches to its active colors/image (like a pressed key) and rotates by `accumulatedRevolutions × 360° × sensitivity`. @@ -83,6 +94,13 @@ await dmn.knobItems.updatePositions(positions); Subscribes to knob layout changes (`knobPositions:changed`). + + This event remains available for compatibility, but is deprecated for new + editor state synchronization. Use + [`dmn.editor.onCommitted()`](/docs/api-reference/editor#subscribe-to-committed-changes) + for the canonical atomic change stream. + + ```typescript const unsub = dmn.knobItems.onPositionsChanged((positions) => { console.log('knob layout changed', positions); diff --git a/docs/content/en/api-reference/plugin/page.mdx b/docs/content/en/api-reference/plugin/page.mdx index 3329c4b6..e259a0f9 100644 --- a/docs/content/en/api-reference/plugin/page.mdx +++ b/docs/content/en/api-reference/plugin/page.mdx @@ -9,7 +9,7 @@ Core plugin registration and management API. ## Element Definition -### `dmn.plugin.defineElement(options): void` +### `dmn.plugin.defineElement(definition): void` Defines a draggable overlay element. See [Declarative API](/docs/declarative-api) for details. @@ -44,17 +44,200 @@ dmn.plugin.defineElement({ --- +## PluginDefinition + +The definition object passed to `defineElement`. + +```typescript +interface PluginDefinition { + /** Plugin name (shown in the context menu) */ + name: string; + + /** Maximum instance count (0 = unlimited, default) */ + maxInstances?: number; + + /** + * Whether the element can be resized in the grid + * @default false + */ + resizable?: boolean; + + /** + * Which size axis to preserve when settings change + * Only applies when resizable is true + * - 'width': preserve width, height follows content + * - 'height': preserve height, width follows content + * - 'both': preserve both (default) + * - 'none': both follow content + * @default 'both' + */ + preserveAxis?: "width" | "height" | "both" | "none"; + + /** + * Resize anchor (fixed point when the size changes) + * @default "top-left" + */ + resizeAnchor?: ElementResizeAnchor; + + /** Context menu setup */ + contextMenu?: { + create?: string; // Create menu label + delete?: string; // Delete menu label + items?: PluginDefinitionContextMenuItem[]; + }; + + /** + * Overlay state keys mirrored to the main window for menu predicates + * (opt-in, low-frequency). Only declared keys are sent on setState changes + */ + contextMenuStateKeys?: string[]; + + /** + * Settings UI mode + * @default "panel" + */ + settingsUI?: "panel" | "modal"; + + /** Settings schema */ + settings?: Record; + + /** i18n messages (nested objects allowed — PluginMessages) */ + messages?: Record>; + + /** Preview state (for the main window) */ + previewState?: Record; + + /** Template function */ + template: ( + state: Record, + settings: Record, + helpers: DisplayElementTemplateHelpers + ) => DisplayElementTemplateResult | string; + + /** Mount logic (runs only in the overlay) */ + onMount?: (context: PluginDefinitionHookContext) => void | (() => void); +} +``` + +## ElementResizeAnchor + +The type that specifies the fixed point when an element's size changes. + +```typescript +type ElementResizeAnchor = + | "top-left" // Top-left (default) + | "top-center" // Top center + | "top-right" // Top-right + | "center-left" // Center-left + | "center" // Center + | "center-right" // Center-right + | "bottom-left" // Bottom-left + | "bottom-center" // Bottom center + | "bottom-right"; // Bottom-right +``` + +### Anchor Behavior + +When the size changes, the specified anchor position stays fixed and the element +expands or shrinks in the other directions. + +| Anchor | Behavior | +| -------------- | ------------------------------------ | +| `top-left` | Expands/shrinks right and down | +| `center` | Expands/shrinks evenly in all directions | +| `bottom-right` | Expands/shrinks left and up | + +## PluginDefinitionHookContext + +The context object passed to the `onMount` callback. + +```typescript +interface PluginDefinitionHookContext { + /** Update state */ + setState: (updates: Record) => void; + + /** Get current settings */ + getSettings: () => Record; + + /** Set the resize anchor */ + setAnchor: (anchor: ElementResizeAnchor) => void; + + /** Get the current resize anchor */ + getAnchor: () => ElementResizeAnchor; + + /** + * Register event hooks + * - "key": mapped key events + * - "rawKey": all raw input events + */ + onHook: (event: "key" | "rawKey", callback: (...args: any[]) => void) => void; + + /** Expose functions for the context menu */ + expose: (actions: Record any>) => void; + + /** Current locale code */ + locale: string; + + /** Translation function */ + t: PluginTranslateFn; + + /** Subscribe to locale changes */ + onLocaleChange: (listener: (locale: string) => void) => Unsubscribe; + + /** Subscribe to settings changes */ + onSettingsChange: ( + listener: ( + newSettings: Record, + oldSettings: Record + ) => void + ) => void; +} +``` + +### setAnchor / getAnchor + +Dynamically change or read the resize anchor per instance. + +```javascript fragment=object-members +onMount: ({ setAnchor, getAnchor, getSettings }) => { + // Check the current anchor + console.log("Current anchor:", getAnchor()); // "top-left" (default) + + // Change the anchor to center + setAnchor("center"); + + // Change the anchor based on settings + const settings = getSettings(); + if (settings.expandFromCenter) { + setAnchor("center"); + } +}, +``` + +**Anchor priority:** + +1. Per-instance anchor set via `setAnchor()` +2. `PluginDefinition.resizeAnchor` (definition default) +3. `"top-left"` (system default) + +--- + ## Settings Definition -### `dmn.plugin.defineSettings(options): void` +### `dmn.plugin.defineSettings(definition): PluginSettingsInstance` -Defines plugin settings UI in the main window settings panel. See [Settings System](/docs/settings) for details. +Defines **plugin-global settings** independent of elements (`defineElement`). The +returned instance exposes `get`/`set`/`open`/`reset`/`subscribe`. See +[Settings System](/docs/settings) for details. -```javascript -dmn.plugin.defineSettings({ - id: "my-settings", - title: "My Plugin Settings", +```typescript fragment=signature +dmn.plugin.defineSettings( + definition: PluginSettingsDefinition +): PluginSettingsInstance +``` +```javascript +const pluginSettings = dmn.plugin.defineSettings({ settings: { enabled: { type: "boolean", default: true, label: "Enable" }, theme: { @@ -70,6 +253,38 @@ dmn.plugin.defineSettings({ }); ``` +### PluginSettingSchema + +Settings use a discriminated union of value settings and sections. Layout entries +never appear in stored settings, defaults, getters, or change callbacks. + +```typescript +type PluginSettingSchema = + | { + type: "boolean" | "color" | "number" | "string" | "select"; + default: string | number | boolean; + label: string; + min?: number; + max?: number; + step?: number; + options?: { label: string; value: string | number | boolean }[]; + placeholder?: string; + visible?: boolean | ((settings: Record) => boolean); + } + | { + type: "section"; + label?: string; // optional caption above the card + visible?: boolean | ((settings: Record) => boolean); + }; +``` + +A section starts a card and controls the whole group through `visible`. Its key is +a layout identifier and never appears in stored values, defaults, getters, or +change callbacks. Panel and modal settings UIs follow the same rules, and +visibility exceptions hide the affected item or group (fail-closed). The legacy +`divider` type was removed — divider entries in existing plugins are ignored as +an unsupported type. + --- ## Lifecycle @@ -100,6 +315,16 @@ dmn.plugin.registerCleanup(() => { Plugin-specific persistent storage. See [Storage API](/docs/storage) for details. +```typescript fragment=signature +dmn.plugin.storage.get(key: string): Promise +dmn.plugin.storage.set(key: string, value: any): Promise +dmn.plugin.storage.remove(key: string): Promise +dmn.plugin.storage.clear(): Promise +dmn.plugin.storage.keys(): Promise +dmn.plugin.storage.hasData(prefix: string): Promise +dmn.plugin.storage.clearByPrefix(prefix: string): Promise +``` + ```javascript // Save await dmn.plugin.storage.set("myKey", { value: 42 }); @@ -116,37 +341,98 @@ const keys = await dmn.plugin.storage.keys(); --- -## Plugin Info - -### `dmn.plugin.id: string` +## Example Usage -Returns current plugin ID (from `// @id` comment). +### Basic Plugin ```javascript -// @id my-plugin -console.log(dmn.plugin.id); // 'my-plugin' -``` +// @id simple-counter -### `dmn.plugin.list(): Promise` +dmn.plugin.defineElement({ + name: "Simple Counter", + maxInstances: 3, + resizeAnchor: "top-left", -Returns list of all loaded plugins. + settings: { + textColor: { + type: "color", + default: "#FFFFFF", + label: "Text Color", + }, + }, -```typescript -interface PluginInfo { - id: string; - name: string; - instances: number; -} + previewState: { + count: 42, + }, + + template: (state, settings, { html }) => html` +
+ Count: ${state.count ?? 0} +
+ `, + + onMount: ({ setState, onHook }) => { + let count = 0; -const plugins = await dmn.plugin.list(); -plugins.forEach((p) => { - console.log(`${p.name} (${p.id}): ${p.instances} instances`); + onHook("key", ({ state }) => { + if (state === "DOWN") { + count++; + setState({ count }); + } + }); + }, }); ``` ---- +### Dynamic Anchor Change -## Example Usage +```javascript +// @id dynamic-anchor-panel + +dmn.plugin.defineElement({ + name: "Dynamic Anchor Panel", + resizeAnchor: "top-left", // Default anchor + + settings: { + expandFromCenter: { + type: "boolean", + default: false, + label: "Expand From Center", + }, + }, + + template: (state, settings, { html }) => html` +
+ Anchor: ${state.currentAnchor ?? "top-left"} +
+ `, + + onMount: ({ + setState, + getSettings, + setAnchor, + getAnchor, + onSettingsChange, + }) => { + // Set the initial anchor + const settings = getSettings(); + const anchor = settings.expandFromCenter ? "center" : "top-left"; + setAnchor(anchor); + setState({ currentAnchor: anchor }); + + // Update the anchor when settings change + onSettingsChange((newSettings, oldSettings) => { + if (newSettings.expandFromCenter !== oldSettings.expandFromCenter) { + const newAnchor = newSettings.expandFromCenter ? "center" : "top-left"; + setAnchor(newAnchor); + setState({ currentAnchor: newAnchor }); + } + }); + }, +}); +``` + +### Stats Tracker ```javascript // @id stats-tracker diff --git a/docs/content/en/api-reference/resources/page.mdx b/docs/content/en/api-reference/resources/page.mdx index 57679eac..4c45dd24 100644 --- a/docs/content/en/api-reference/resources/page.mdx +++ b/docs/content/en/api-reference/resources/page.mdx @@ -29,48 +29,6 @@ const result = await dmn.image.load(); ## Sound -### `dmn.sound.load()` +See the dedicated [Sound API reference](/docs/api-reference/sound) for sound library visibility, editing, and compatibility APIs. -Opens the system file picker, imports a sound, and returns its app-managed path. - -### `dmn.sound.list()` - -Returns the available local and built-in sounds. - -```typescript -type SoundListItem = { - soundPath: string; - fileName: string; - sizeBytes: number; - modifiedAtMs?: number; - enabled: boolean; - source: 'local' | 'builtin'; - originalPath?: string; - trimStartRatio?: number; - trimEndRatio?: number; - displayName?: string; -}; -``` - -### `dmn.sound.rename(soundPath, displayName)` - -Changes the display name of a local sound. Built-in sounds cannot be renamed. - -### `dmn.sound.remove(soundPath)` - -Deletes a local sound. Built-in sounds cannot be deleted. - -### `dmn.sound.setEnabled(soundPath, enabled)` - -> Deprecated: retained for compatibility with existing plugins. The current sound picker does not use the enabled state. - -Updates and returns the legacy enabled state. - -```typescript -const result = await dmn.sound.setEnabled(soundPath, false); -// { success: true, soundPath, enabled: false } -``` - -### Sound editing - -`saveProcessedWav`, `loadOriginal`, and `updateProcessedWav` support the built-in sound editor. Paths returned by the resource APIs must be treated as opaque app-managed identifiers. +Paths returned by resource APIs must be treated as opaque app-managed identifiers. diff --git a/docs/content/en/api-reference/sound/page.mdx b/docs/content/en/api-reference/sound/page.mdx new file mode 100644 index 00000000..30f2a980 --- /dev/null +++ b/docs/content/en/api-reference/sound/page.mdx @@ -0,0 +1,105 @@ +--- +title: Sound API +description: dmn.sound API reference for the key sound library +--- + +# Sound API + +Manage the key sound library — the pool of audio files that key elements can +play on press. Sounds live in the app data `sounds` directory and include both +user-imported files (`local`) and bundled ones (`builtin`). + +## Types + +```typescript +type SoundListItem = { + soundPath: string; // absolute path — also the stable identifier + fileName: string; + sizeBytes: number; + modifiedAtMs?: number; + hidden: boolean; // hidden from picker lists — playback is unaffected + enabled: boolean; // deprecated — inverse alias of hidden, kept for 1.6.1 compat + source: 'local' | 'builtin'; + originalPath?: string; // pre-trim original (present for edited sounds) + trimStartRatio?: number; + trimEndRatio?: number; + displayName?: string; +}; +``` + +## Library + +### `dmn.sound.list(): Promise` + +Returns every sound in the library, **including hidden ones** — filtering by +`hidden` is up to the caller (the built-in picker hides them from its default +views and shows them under a "Hidden Sounds" filter). + +```typescript +const sounds = await dmn.sound.list(); +const visible = sounds.filter((s) => !s.hidden); +``` + +### `dmn.sound.load(): Promise` + +Opens a file dialog, copies the chosen audio file into the library, and +returns its `soundPath`. + +### `dmn.sound.rename(soundPath, displayName): Promise` + +Sets the display name shown in pickers. The file itself is not renamed. + +### `dmn.sound.remove(soundPath): Promise` + +Deletes the file and unassigns the sound from every element using it. + +## Visibility + +### `dmn.sound.setHidden(soundPath, hidden): Promise` + +Hides a sound from (or restores it to) picker lists. Hiding is list-only +housekeeping: **keys that already use the sound keep playing it**. Builtin +sounds can be hidden too. + +```typescript +await dmn.sound.setHidden(sound.soundPath, true); // hide +await dmn.sound.setHidden(sound.soundPath, false); // unhide +``` + +```typescript +type SoundSetHiddenResult = { + success: boolean; + soundPath: string; + hidden: boolean; +}; +``` + +### `dmn.sound.setEnabled(soundPath, enabled): Promise` + + + Deprecated — inverse alias kept for backward compatibility. `enabled` is + simply `!hidden`; use `setHidden` instead. + + +## Editing + +### `dmn.sound.saveProcessedWav(wavBase64, fileName?, originalBase64?, originalExtension?, trimStartRatio?, trimEndRatio?): Promise` + +Saves a processed (trimmed) WAV into the library, optionally alongside the +original for later re-editing. + +### `dmn.sound.loadOriginal(soundPath): Promise` + +Returns the stored original audio (base64) of an edited sound. + +### `dmn.sound.updateProcessedWav(soundPath, wavBase64, trimStartRatio?, trimEndRatio?, displayName?): Promise` + +Overwrites an edited sound's processed WAV in place. + +## Diagnostics + +### `dmn.sound.setLatencyLogging(enabled): Promise` + +Toggles key-sound latency logging in the audio engine. Enabling is only +available in dev/debug builds — release builds reject `enabled: true` with an +error. diff --git a/docs/content/en/custom-css/counter-styling/page.mdx b/docs/content/en/custom-css/counter-styling/page.mdx index f4d9159b..ff9c401e 100644 --- a/docs/content/en/custom-css/counter-styling/page.mdx +++ b/docs/content/en/custom-css/counter-styling/page.mdx @@ -25,7 +25,7 @@ Set styles that apply to all counters: font-weight: 700; /* Font family */ - font-family: "Roboto Mono", monospace; + font-family: 'Roboto Mono', monospace; } ``` @@ -33,18 +33,27 @@ Set styles that apply to all counters: Counters support the following CSS variables: -| Variable | Description | Default | -| ------------------------ | ---------------------- | ------------- | -| `--counter-color` | Text color | `#FFFFFF` | -| `--counter-stroke-color` | Text outline color | `transparent` | -| `--counter-stroke-width` | Text outline thickness | `0px` | +| Variable | Description | Default | +| --------------------------- | ------------------------------- | -------------- | +| `--counter-color` | Text color and saved-fill reset | `#FFFFFF` | +| `--counter-fill-image` | Text fill image | `none` | +| `--counter-fill-clip` | Fill clipping area | `border-box` | +| `--counter-text-fill-color` | WebKit text fill color | `currentcolor` | +| `--counter-stroke-color` | Text outline color | `transparent` | +| `--counter-stroke-width` | Text outline thickness | `0px` | + + + With **Inline Styles Priority** disabled on the key or stat item, regular CSS + also takes priority over counter typography. Setting `--counter-color` + automatically removes a fill gradient saved in the properties panel. + ## Inactive State Style Counter style when the key is not pressed: ```css -.counter[data-counter-state="inactive"] { +.counter[data-counter-state='inactive'] { /* Text color */ --counter-color: #474244; @@ -58,7 +67,7 @@ Counter style when the key is not pressed: Counter style when the key is being pressed: ```css -.counter[data-counter-state="active"] { +.counter[data-counter-state='active'] { /* Text color */ --counter-color: #ff2b80; @@ -78,12 +87,12 @@ Combine class selectors to style counters of specific keys differently. ```css /* Counter for keys with .blue class - inactive state */ -.blue .counter[data-counter-state="inactive"] { +.blue .counter[data-counter-state='inactive'] { --counter-color: #1a4a5c; } /* Counter for keys with .blue class - active state */ -.blue .counter[data-counter-state="active"] { +.blue .counter[data-counter-state='active'] { --counter-color: #2ebef3; --counter-stroke-color: transparent; text-shadow: 0px 0px 3px #2ebef3; @@ -105,16 +114,13 @@ Combine class selectors to style counters of specific keys differently. font-weight: 700; } -.counter[data-counter-state="inactive"] { +.counter[data-counter-state='inactive'] { --counter-color: #333; } -.counter[data-counter-state="active"] { +.counter[data-counter-state='active'] { --counter-color: #00ff88; - text-shadow: - 0px 0px 2px #00ff88, - 0px 0px 4px #00ff88, - 0px 0px 8px #00ff88; + text-shadow: 0px 0px 2px #00ff88, 0px 0px 4px #00ff88, 0px 0px 8px #00ff88; } ``` @@ -126,13 +132,13 @@ Combine class selectors to style counters of specific keys differently. font-weight: 900; } -.counter[data-counter-state="inactive"] { +.counter[data-counter-state='inactive'] { --counter-color: #fff; --counter-stroke-color: #333; --counter-stroke-width: 2px; } -.counter[data-counter-state="active"] { +.counter[data-counter-state='active'] { --counter-color: #fff; --counter-stroke-color: #ff2b80; --counter-stroke-width: 2px; @@ -145,14 +151,14 @@ Combine class selectors to style counters of specific keys differently. .counter { font-size: 14px; font-weight: 400; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } -.counter[data-counter-state="inactive"] { +.counter[data-counter-state='inactive'] { --counter-color: #999; } -.counter[data-counter-state="active"] { +.counter[data-counter-state='active'] { --counter-color: #333; } ``` @@ -160,7 +166,7 @@ Combine class selectors to style counters of specific keys differently. ### Rainbow Gradient Style ```css -.counter[data-counter-state="active"] { +.counter[data-counter-state='active'] { background: linear-gradient( 45deg, #ff2b80, @@ -195,7 +201,7 @@ Example of keeping key and counter colors consistent: ```css /* Base key - pink theme */ -[data-state="active"] { +[data-state='active'] { --key-border: 3px solid #ff2b80; --key-text-color: #ff2b80; text-shadow: 0px 0px 3px #ff2b80; diff --git a/docs/content/en/custom-css/graph-styling/page.mdx b/docs/content/en/custom-css/graph-styling/page.mdx index 858d99c3..3606ddb6 100644 --- a/docs/content/en/custom-css/graph-styling/page.mdx +++ b/docs/content/en/custom-css/graph-styling/page.mdx @@ -27,19 +27,22 @@ When **Inline Styles Priority** is enabled, property panel values take precedenc For graphs, **Inline Styles Priority is disabled by default**, so CSS-variable-based control works immediately. - Recommended: use class selectors to style specific graphs instead of broad global selectors. + Recommended: use class selectors to style specific graphs instead of broad + global selectors. ## Graph CSS Variables Available graph variables: -| Variable | Description | Type | Example | -| ---------------- | ---------------------- | ---------- | ------------------------ | -| `--graph-bg` | Graph background | `` | `rgba(17, 17, 20, 0.85)` | -| `--graph-border` | Graph border | `` | `1px solid #7dd3fc` | -| `--graph-radius` | Graph corner radius | `` | `10px` | -| `--graph-color` | Line/bar drawing color | `` | `#86efac` | +| Variable | Description | Type | Example | +| ---------------------- | ---------------------------------- | ---------- | ------------------------------------ | +| `--graph-bg` | Graph background | `` | `rgba(17, 17, 20, 0.85)` | +| `--graph-border` | Border and saved border-ring reset | `` | `1px solid #7dd3fc`, `none` | +| `--graph-border-image` | Replacement image for a saved ring | `` | `linear-gradient(90deg, #f0f, #0ff)` | +| `--graph-padding` | Graph inner padding | `` | `0px`, `3px` | +| `--graph-radius` | Graph corner radius | `` | `10px` | +| `--graph-color` | Line/bar drawing color | `` | `#86efac` | ### Example @@ -81,4 +84,5 @@ Available graph variables: ## Notes - Graph position offsets use the same variables as keys: `--key-offset-x`, `--key-offset-y`. -- To completely hide the border, use `--graph-border: none;` or set border width to `0` in the properties panel. +- `--graph-border: none;` removes the solid border, a gradient border ring + saved in the properties panel, and its image inset together. diff --git a/docs/content/en/custom-css/key-styling/page.mdx b/docs/content/en/custom-css/key-styling/page.mdx index b848e242..fde278be 100644 --- a/docs/content/en/custom-css/key-styling/page.mdx +++ b/docs/content/en/custom-css/key-styling/page.mdx @@ -14,13 +14,20 @@ Keys indicate their current state through the `data-state` attribute: - `data-state="inactive"`: Key not pressed - `data-state="active"`: Key being pressed + + With **Inline Styles Priority** disabled, property-panel solid colors, + gradients, borders, and typography are lower-priority defaults. Setting + `--key-bg` or `--key-border` also removes the corresponding background or + border gradient saved in the app. + + ## Global Key Styles Set styles that apply to all keys: ```css -[data-state="inactive"], -[data-state="active"] { +[data-state='inactive'], +[data-state='active'] { /* Corner roundness */ --key-radius: 8px; @@ -38,7 +45,7 @@ Set styles that apply to all keys: Styles when the key is not pressed: ```css -[data-state="inactive"] { +[data-state='inactive'] { /* Background color */ --key-bg: #2a2a2a; @@ -58,7 +65,7 @@ Styles when the key is not pressed: Styles when the key is being pressed: ```css -[data-state="active"] { +[data-state='active'] { /* Background color */ --key-bg: #ff2b80; @@ -81,7 +88,7 @@ Styles when the key is being pressed: You can implement a press effect where the key moves when pressed: ```css -[data-state="active"] { +[data-state='active'] { /* X offset */ --key-offset-x: 0px; @@ -113,7 +120,7 @@ Use class names to apply different styles to specific keys. ```css /* Active state for keys with .blue class */ -.blue[data-state="active"] { +.blue[data-state='active'] { --key-bg: transparent; --key-border: 3px solid #2ebef3; --key-text-color: #2ebef3; @@ -122,7 +129,7 @@ Use class names to apply different styles to specific keys. } /* Active state for keys with .special class */ -.special[data-state="active"] { +.special[data-state='active'] { --key-bg: transparent; background: linear-gradient(45deg, #ff2b80, #2ebef3); --key-border: none; @@ -135,49 +142,44 @@ Use class names to apply different styles to specific keys. - ✅ Correct: `blue` - ❌ Wrong: `.blue` - + ## Style Examples ### Neon Sign Style ```css -[data-state="inactive"] { +[data-state='inactive'] { --key-bg: transparent; --key-border: 3px solid #333; --key-text-color: #333; } -[data-state="active"] { +[data-state='active'] { --key-bg: transparent; --key-border: 3px solid #ff2b80; --key-text-color: #ff2b80; - text-shadow: - 0px 0px 2px #ff2b80, - 0px 0px 4px #ff2b80, - 0px 0px 8px #ff2b80; - box-shadow: - 0px 0px 4px #ff2b80, - inset 0px 0px 4px rgba(255, 43, 128, 0.3); + text-shadow: 0px 0px 2px #ff2b80, 0px 0px 4px #ff2b80, 0px 0px 8px #ff2b80; + box-shadow: 0px 0px 4px #ff2b80, inset 0px 0px 4px rgba(255, 43, 128, 0.3); } ``` ### Minimal Flat Style ```css -[data-state="inactive"], -[data-state="active"] { +[data-state='inactive'], +[data-state='active'] { --key-radius: 4px; transition: 0.05s ease-out; } -[data-state="inactive"] { +[data-state='inactive'] { --key-bg: #f0f0f0; --key-border: none; --key-text-color: #333; } -[data-state="active"] { +[data-state='active'] { --key-bg: #333; --key-border: none; --key-text-color: #fff; @@ -187,7 +189,7 @@ Use class names to apply different styles to specific keys. ### Pastel 3D Style ```css -[data-state="inactive"] { +[data-state='inactive'] { --key-radius: 20px; --key-bg: #ffd4e5; --key-border: 2px solid #e8b4c8; @@ -195,7 +197,7 @@ Use class names to apply different styles to specific keys. box-shadow: 0px 4px 0px #d9a0b8; } -[data-state="active"] { +[data-state='active'] { --key-bg: #ffc4dd; --key-border: 2px solid #e8b4c8; --key-text-color: #8b5a6b; diff --git a/docs/content/en/custom-css/page.mdx b/docs/content/en/custom-css/page.mdx index ec90f103..d721fada 100644 --- a/docs/content/en/custom-css/page.mdx +++ b/docs/content/en/custom-css/page.mdx @@ -48,9 +48,20 @@ For counters: ## Applying CSS Files 1. Create a CSS file with your desired styles. -2. Go to the **Settings** tab. -3. Click **Select CSS File** in the **Custom CSS** section. -4. Select your CSS file. +2. Go to the **Settings** tab and click **Manage CSS**. +3. Turn on the **Enable** toggle, then click **Import CSS File**. +4. Select your CSS file (`.css`, up to 1 MiB). Previously imported files stay in the panel list, ready to re-apply with one click. + +## Per-Tab CSS + +Each key tab can override the global CSS with its own file. Right-click the grid and choose the tab CSS menu to open the popover: + +- **Import**: pick a CSS file that applies to the current tab only. +- **History list**: files already imported in the custom CSS panel appear below the file actions; click **Apply** on an entry to use it for this tab. +- **Export**: save the CSS currently registered on this tab back to a `.css` file. This is handy when the original file was moved or deleted, since the tab keeps its own copy of the content. +- The toggle at the top enables or disables the tab override without removing it. + +Tab CSS is applied only while the global **Custom CSS** toggle is on. ## Quick Start Example diff --git a/docs/content/en/custom-css/variables/page.mdx b/docs/content/en/custom-css/variables/page.mdx index dbbb0d4b..1c2175e7 100644 --- a/docs/content/en/custom-css/variables/page.mdx +++ b/docs/content/en/custom-css/variables/page.mdx @@ -11,32 +11,39 @@ This page lists all CSS variables available for styling in DM Note. CSS variables applied to key elements. Use with `[data-state="inactive"]` or `[data-state="active"]` selectors. -| Variable | Description | Type | Example | -| ------------------ | ------------------------ | ---------- | ------------------------ | -| `--key-radius` | Corner roundness | `` | `8px`, `50%` | -| `--key-bg` | Background color | `` | `#ff2b80` | -| `--key-border` | Border style | `` | `2px solid #fff`, `none` | -| `--key-text-color` | Text color | `` | `#ffffff` | -| `--key-offset-x` | X offset in active state | `` | `0px`, `2px` | -| `--key-offset-y` | Y offset in active state | `` | `4px` | +| Variable | Description | Type | Example | +| --------------------- | -------------------------------------- | ---------- | ------------------------------------ | +| `--key-radius` | Corner roundness | `` | `8px`, `50%` | +| `--key-bg` | Background color and saved-image reset | `` | `#ff2b80` | +| `--key-bg-image` | Background image | `` | `linear-gradient(90deg, #f0f, #0ff)` | +| `--key-border` | Border and saved border-ring reset | `` | `2px solid #fff`, `none` | +| `--key-border-image` | Replacement image for a saved ring | `` | `linear-gradient(90deg, #f0f, #0ff)` | +| `--key-padding` | Inner padding | `` | `0px`, `3px` | +| `--key-text-color` | Text color | `` | `#ffffff` | +| `--key-shadow` | Idle and shared fallback shadow | `` | `0 4px 10px rgba(0,0,0,.28)`, `none` | +| `--key-active-shadow` | Active shadow | `` | `0 3px 8px rgba(0,0,0,.32)`, `none` | +| `--key-offset-x` | X offset in active state | `` | `0px`, `2px` | +| `--key-offset-y` | Y offset in active state | `` | `4px` | - `--key-bg` is applied as `background-color`. To use gradients, specify - `background` or `background-image` directly and set `--key-bg: transparent;` - if needed. + With **Inline Styles Priority** disabled, property-panel colors, gradients, + borders, typography, and shadows are low-priority defaults only. Setting + `--key-bg` automatically removes a saved background gradient. Setting + `--key-border` removes the saved gradient border ring and its spacing. Use + `--key-bg-image` or the standard `background` property for a custom gradient. ### Usage Example ```css -[data-state="inactive"] { +[data-state='inactive'] { --key-radius: 8px; --key-bg: #2a2a2a; --key-border: 2px solid #444; --key-text-color: #888; } -[data-state="active"] { +[data-state='active'] { --key-radius: 8px; --key-bg: #ff2b80; --key-border: 2px solid #ff2b80; @@ -50,22 +57,36 @@ CSS variables applied to key elements. Use with `[data-state="inactive"]` or `[d CSS variables applied to counter elements. Use with `.counter[data-counter-state="..."]` selectors. -| Variable | Description | Type | Example | -| ------------------------ | ---------------------- | ---------- | ------------------------ | -| `--counter-color` | Counter text color | `` | `#ffffff` | -| `--counter-stroke-color` | Text outline color | `` | `#000000`, `transparent` | -| `--counter-stroke-width` | Text outline thickness | `` | `1px`, `2px` | +| Variable | Description | Type | Example | +| --------------------------- | ------------------------------- | ---------- | ------------------------------------ | +| `--counter-color` | Text color and saved-fill reset | `` | `#ffffff` | +| `--counter-fill-image` | Text fill image | `` | `linear-gradient(90deg, #f0f, #0ff)` | +| `--counter-fill-clip` | Fill clipping area | `` | `text`, `border-box` | +| `--counter-text-fill-color` | WebKit text fill color | `` | `transparent`, `currentcolor` | +| `--counter-stroke-color` | Text outline color | `` | `#000000`, `transparent` | +| `--counter-stroke-width` | Text outline thickness | `` | `1px`, `2px` | + +Setting only `--counter-color` also disables a counter fill gradient saved in +the app. To create a CSS fill gradient, set all three fill variables: + +```css +.counter { + --counter-fill-image: linear-gradient(90deg, #f0f, #0ff); + --counter-fill-clip: text; + --counter-text-fill-color: transparent; +} +``` ### Usage Example ```css -.counter[data-counter-state="inactive"] { +.counter[data-counter-state='inactive'] { --counter-color: #666; --counter-stroke-color: transparent; --counter-stroke-width: 0px; } -.counter[data-counter-state="active"] { +.counter[data-counter-state='active'] { --counter-color: #ff2b80; --counter-stroke-color: #fff; --counter-stroke-width: 1px; @@ -77,12 +98,14 @@ CSS variables applied to counter elements. Use with `.counter[data-counter-state CSS variables available for graph elements. Disable **Inline Styles Priority** on the graph to control these via CSS. -| Variable | Description | Type | Example | -| ---------------- | ---------------------- | ---------- | ------------------------ | -| `--graph-bg` | Graph background | `` | `rgba(17, 17, 20, 0.85)` | -| `--graph-border` | Graph border | `` | `1px solid #7dd3fc` | -| `--graph-radius` | Graph corner radius | `` | `10px` | -| `--graph-color` | Line/bar drawing color | `` | `#86efac` | +| Variable | Description | Type | Example | +| ---------------------- | ---------------------------------- | ---------- | ------------------------------------ | +| `--graph-bg` | Graph background | `` | `rgba(17, 17, 20, 0.85)` | +| `--graph-border` | Border and saved border-ring reset | `` | `1px solid #7dd3fc`, `none` | +| `--graph-border-image` | Replacement image for a saved ring | `` | `linear-gradient(90deg, #f0f, #0ff)` | +| `--graph-padding` | Graph inner padding | `` | `0px`, `3px` | +| `--graph-radius` | Graph corner radius | `` | `10px` | +| `--graph-color` | Line/bar drawing color | `` | `#86efac` | ### Usage Example @@ -95,6 +118,31 @@ Disable **Inline Styles Priority** on the graph to control these via CSS. } ``` +## Knob Style Variables + +Disable **Inline Styles Priority** on a knob to use these variables. The custom +class is on the position wrapper, so target `.knob-class [data-knob-element]` +when applying standard properties directly to the visible surface. + +| Variable | Description | Type | Example | +| ---------------------- | ---------------------------------- | ---------- | ------------------------------------ | +| `--knob-bg` | Knob background | `` | `#222` | +| `--knob-border` | Border and saved border-ring reset | `` | `2px solid #fff`, `none` | +| `--knob-border-image` | Replacement image for a saved ring | `` | `linear-gradient(90deg, #f0f, #0ff)` | +| `--knob-padding` | Knob inner padding | `` | `0px`, `3px` | +| `--knob-radius` | Corner radius | `` | `50%`, `8px` | +| `--knob-shadow` | Knob shadow | `` | `0 4px 10px rgba(0,0,0,.3)` | +| `--knob-active-shadow` | Turning-state shadow | `` | `0 3px 8px rgba(0,0,0,.32)` | +| `--knob-indicator` | Center rotation indicator color | `` | `#fff` | + +```css +.volume-knob { + --knob-bg: #17171b; + --knob-border: none; + --knob-indicator: #ff2b80; +} +``` + ## Selector Reference ### Key Selectors @@ -117,21 +165,33 @@ Disable **Inline Styles Priority** on the graph to control these via CSS. ### Graph Selectors -| Selector | Description | -| ------------------------- | ------------------------------------------------ | -| `.graphClassName` | Style a specific graph container | -| `.graphClassName svg` | Style line graph SVG (stroke/fill) | -| `.graphClassName > div` | Style the inner plot area (line/bar shared) | -| `.graphClassName > div > div` | Style individual bars in bar mode | +| Selector | Description | +| ----------------------------- | ------------------------------------------- | +| `.graphClassName` | Style a specific graph container | +| `.graphClassName svg` | Style line graph SVG (stroke/fill) | +| `.graphClassName > div` | Style the inner plot area (line/bar shared) | +| `.graphClassName > div > div` | Style individual bars in bar mode | + +### Knob Selectors + +| Selector | Description | +| -------------------------------- | ------------------------------- | +| `[data-knob-element]` | Every visible knob surface | +| `[data-knob-state="inactive"]` | Idle knob surfaces | +| `[data-knob-state="active"]` | Active knob surfaces | +| `.knobClass [data-knob-element]` | A specific class's knob surface | +| `[data-knob-indicator]` | Center rotation indicator | ## Standard CSS Properties -In addition to CSS variables, you can freely use the following standard CSS properties: +With **Inline Styles Priority** disabled, you can directly use standard visual +properties such as the following. Element positioning remains app-managed; use +`--key-offset-x`/`--key-offset-y` to move keys, graphs, and knobs. ### Commonly Used Properties ```css -[data-state="active"] { +[data-state='active'] { /* Shadow effect */ box-shadow: 0px 0px 8px #ff2b80; @@ -144,10 +204,7 @@ In addition to CSS variables, you can freely use the following standard CSS prop /* Font style */ font-size: 24px; font-weight: 700; - font-family: "Roboto", sans-serif; - - /* Transform */ - transform: scale(1.05); + font-family: 'Roboto', sans-serif; /* Filter */ filter: brightness(1.2); @@ -158,18 +215,22 @@ In addition to CSS variables, you can freely use the following standard CSS prop ### Using !important -Use `!important` if some styles are not being applied: +Normal declarations lose to inline values only when **Inline Styles Priority** +is enabled. In that mode, override the rendered CSS property itself with +`!important`; adding it to a fallback custom property cannot beat an inline +property declaration: ```css -[data-state="active"] { - --key-bg: #ff2b80 !important; +[data-state='active'] { + background: #ff2b80 !important; box-shadow: 0px 0px 8px #ff2b80 !important; } ``` ### Browser Compatibility -DM Note runs on Chromium-based WebView2, supporting most modern CSS features: +DM Note uses WebView2 on Windows and WKWebView on macOS. You can use modern CSS +features supported by the WebView on the current operating system: - CSS Variables (Custom Properties) ✅ - CSS Grid / Flexbox ✅ @@ -190,17 +251,17 @@ DM Note runs on Chromium-based WebView2, supporting most modern CSS features: ```css /* Priority: 1 (lowest) */ -[data-state="active"] { +[data-state='active'] { --key-bg: red; } /* Priority: 2 */ -.blue[data-state="active"] { +.blue[data-state='active'] { --key-bg: blue; } /* Priority: 3 (highest) */ -.blue.special[data-state="active"] { +.blue.special[data-state='active'] { --key-bg: purple; } ``` diff --git a/docs/content/en/declarative-api/page.mdx b/docs/content/en/declarative-api/page.mdx index 33ca6a4a..e28fba9a 100644 --- a/docs/content/en/declarative-api/page.mdx +++ b/docs/content/en/declarative-api/page.mdx @@ -84,15 +84,21 @@ dmn.plugin.defineElement({ }); ``` -## Settings Schema +## Settings Schema (settings) Define settings schema to auto-generate settings UI. Default is property panel; use `settingsUI: "modal"` for modal style. ### Supported Types -```javascript +```javascript fragment=object-members settings: { + // Card section — starts a new settings card + appearanceSection: { + type: "section", + label: "Appearance", // optional caption above the card + }, + // String nickname: { type: "string", @@ -118,11 +124,6 @@ settings: { label: "Show Graph", }, - // Divider - sectionDivider: { - type: "divider", - }, - // Color textColor: { type: "color", @@ -143,14 +144,25 @@ settings: { }, ``` -`type: "divider"` adds only a divider in the settings UI without needing `default` or `label`. +`type: "section"` starts a new card. Its key is a layout identifier and is never +included in stored values, defaults, `getSettings()`, or change callbacks. Settings +before the first section appear in an implicit untitled card. A section without a +label still creates a card boundary. + + + The legacy `type: "divider"` (an in-card separator line) was removed together + with the introduction of sections. Divider entries in existing plugins are + ignored (no crash) — use sections to group settings instead. + + +Sections behave identically in the property panel and with `settingsUI: "modal"`. ### Conditional Visibility (visible) Add a `visible` property to setting items to dynamically show/hide them based on other setting values. Settings without `visible` are always shown (same as existing behavior). -```javascript +```javascript fragment=object-members settings: { // Mode selection showGraph: { @@ -159,6 +171,12 @@ settings: { label: "Show Graph", }, + graphSection: { + type: "section", + label: "Graph", + visible: (settings) => settings.showGraph, + }, + // Static boolean — always hidden hiddenOption: { type: "number", @@ -168,10 +186,6 @@ settings: { }, // Function — dynamically show/hide based on other settings - graphDivider: { - type: "divider", - visible: (settings) => settings.showGraph, - }, graphColor: { type: "color", default: "#86EFAC", @@ -191,15 +205,21 @@ settings: { Hidden settings retain their stored values — only display is controlled, no data loss. +On a `section`, `visible` hides the entire group up to the next section. The boundary +is retained even while hidden, so adjacent groups never merge. If every value setting +inside a section is hidden, its card and caption are hidden too. If a visibility +function throws, that item or section is hidden (fail-closed) and the error is logged. +The same rules and empty-state behavior apply to panel and modal settings UIs. + The `visible` signature follows the same pattern as `PluginMenuItem.visible` in context menus: -```typescript +```typescript fragment=type-members visible?: boolean | ((settings: Record) => boolean); ``` ### Accessing Settings -```javascript +```javascript fragment=object-members // In template template: (state, settings, { html }) => html`
@@ -232,6 +252,12 @@ Sets the anchor position when element size changes. Default is `"top-left"`. | `bottom-center` | Bottom center | | `bottom-right` | Bottom-right | +### Anchor Behavior + +- `top-left`: the top-left corner stays fixed as the size changes (default) +- `center`: the center position stays fixed as the size changes +- `bottom-right`: the bottom-right corner stays fixed as the size changes + ### Definition Level Setting Define default anchor for all instances: @@ -252,7 +278,7 @@ Use `setAnchor()` and `getAnchor()` in `onMount` to dynamically change anchor: dmn.plugin.defineElement({ name: "Dynamic Anchor Panel", - onMount: ({ setAnchor, getAnchor }) => { + onMount: ({ setAnchor, getAnchor, getSettings }) => { // Check current anchor console.log("Current anchor:", getAnchor()); // "top-left" @@ -270,6 +296,12 @@ dmn.plugin.defineElement({ }); ``` +### Anchor Priority + +1. Per-instance `resizeAnchor` (set via `setAnchor`) +2. `resizeAnchor` on the definition +3. Default `"top-left"` + ## Resize Settings (resizable, preserveAxis) Use `resizable` option to let users resize elements directly in the grid. @@ -277,6 +309,7 @@ Use `resizable` option to let users resize elements directly in the grid. ### resizable Set `resizable: true` to show 8-direction resize handles: +The initial size is 200×150. A saved or estimated size takes precedence when available. ```javascript dmn.plugin.defineElement({ @@ -312,3 +345,386 @@ dmn.plugin.defineElement({ // ... }); ``` + +### Usage Example + +For a panel with a graph toggle, like the KPS panel: + +- With `preserveAxis: "width"`, the width is preserved when the graph is toggled on and off +- The height adjusts automatically depending on whether the graph is shown + +```javascript +dmn.plugin.defineElement({ + name: "KPS Panel", + resizable: true, + preserveAxis: "width", + resizeAnchor: "bottom-left", // Bottom fixed (graph expands upward) + + settings: { + showGraph: { type: "boolean", default: true, label: "Show Graph" }, + }, + + template: (state, settings, { html }) => html` +
+
${state.kps ?? 0}
+ ${settings.showGraph ? html`
...
` : ""} +
+ `, +}); +``` + + + Apply `width: 100%; height: 100%` to the root element of a `resizable` + element's template so the content fills the size the user chose. + + +## Context Menu (contextMenu) + +### Basic Setup + +```javascript fragment=object-members +contextMenu: { + create: "Create Panel", // Right-click on empty grid space + delete: "Delete Panel", // Right-click on the panel +}, +``` + +### Custom Menu Items + +```javascript fragment=object-members +contextMenu: { + create: "Create Panel", + delete: "Delete Panel", + items: [ + { + label: "Reset Stats", + onClick: ({ actions }) => actions.reset(), + }, + { + label: "Export Data", + onClick: async ({ element, actions }) => { + await actions.exportData(); + }, + // Conditional visibility + visible: ({ element }) => !!element.settings.enableExport, + // Conditional disable + disabled: ({ element }) => element.settings.exportFormat === "none", + // Position (top or bottom, default: bottom) + position: "bottom", + }, + ], +}, + +// Register actions in onMount +onMount: ({ expose, setState }) => { + expose({ + reset: () => setState({ count: 0 }), + exportData: async () => { /* export logic */ }, + }); +}, +``` + +Items with `position: "top"` appear above the built-in menu entries; the rest +appear below them. + + + Menu predicates run in the main window with `{ element, actions }`. Overlay + runtime state set via `setState` is not synced into this context by default — + `element.state` here reflects the main-window state (initialized from + `previewState`). To base conditions on overlay state, declare the keys in + `contextMenuStateKeys`. + + +### Overlay State in Predicates (contextMenuStateKeys) + +Declare overlay state keys that menu predicates need. Only the declared keys are +mirrored to the main window (on `setState` changes) and merged into +`element.state` during predicate evaluation. High-frequency state is never sent +unless declared, and the main-window preview state stays untouched. + +```javascript fragment=object-members +// Mirror only `active` for menu predicates +contextMenuStateKeys: ["active"], + +contextMenu: { + items: [ + { + label: "Stop Capture", + action: "stopCapture", + visible: ({ element }) => !!element.state?.active, + }, + ], +}, +``` + +## Template (template) + +The template function receives `state` and `settings` and returns the UI. +See [Template Syntax](/docs/template-syntax) for the full syntax. + +```javascript fragment=object-members +template: (state, settings, { html, t, locale }) => html` +
+ ${state.value} + ${settings.showDetails ? html` +
Details
+ ` : ""} +
+`, +``` + +### Template Helpers + +| Helper | Description | +| -------- | ---------------------------------------- | +| `html` | htm tag function (creates React Elements) | +| `t(key)` | i18n translation function | +| `locale` | Current locale code | + +## Preview State (previewState) + +Initial state shown as a preview in the main window. +The actual logic runs only in the overlay, so the main window displays this state. + +```javascript fragment=object-members +previewState: { + kps: 12, + history: [5, 8, 12, 10, 15], +}, +``` + +## Mount Logic (onMount) + +`onMount` runs only in the overlay and implements the actual behavior. + +### Context Object + +```javascript fragment=object-members +onMount: (context) => { + const { + setState, // Update state + getSettings, // Get current settings + setAnchor, // Set resize anchor + getAnchor, // Get current anchor + onHook, // Register event hooks + expose, // Expose functions for the context menu + locale, // Current locale code + t, // Translation function + onLocaleChange, // Subscribe to locale changes + onSettingsChange, // Subscribe to settings changes + } = context; + + // Return cleanup function + return () => { /* cleanup */ }; +}, +``` + +### Event Hooks (onHook) + +```javascript fragment=object-members +onMount: ({ onHook, setState }) => { + // Mapped key events + onHook("key", ({ key, state, mode }) => { + if (state === "DOWN") { + console.log(`${key} pressed (${mode})`); + } + }); + + // All raw input events (keyboard, mouse) + onHook("rawKey", ({ device, label, state }) => { + console.log(`[${device}] ${label} ${state}`); + }); +}, +``` + +### Settings Change Detection (onSettingsChange) + +Use this when you need to react to settings changes immediately. + + + In most cases, reading the latest settings with `getSettings()` is enough. + Use `onSettingsChange` only when you need external API calls or resource + re-initialization. + + +```javascript fragment=object-members +onMount: ({ setState, getSettings, onSettingsChange }) => { + const fetchData = async (nickname) => { + const response = await fetch(`/api/user/${nickname}`); + const data = await response.json(); + setState({ data }); + }; + + // Initial load + fetchData(getSettings().nickname); + + // Refetch when the nickname changes + onSettingsChange((newSettings, oldSettings) => { + if (newSettings.nickname !== oldSettings.nickname) { + fetchData(newSettings.nickname); + } + }); +}, +``` + +## i18n Support (messages) + +```javascript +dmn.plugin.defineElement({ + name: "Localized Panel", + + messages: { + ko: { + "menu.create": "패널 생성", + "menu.delete": "패널 삭제", + "label.count": "카운트", + }, + en: { + "menu.create": "Create Panel", + "menu.delete": "Delete Panel", + "label.count": "Count", + }, + }, + + contextMenu: { + create: "menu.create", // Use message keys + delete: "menu.delete", + }, + + settings: { + count: { + type: "number", + default: 0, + label: "label.count", // Use message keys + }, + }, + + template: (state, settings, { html, t, locale }) => html` +
${t("label.count")}: ${state.value ?? 0}
+ `, +}); +``` + +## Practical Example: KPS Panel + +```javascript +// @id kps-panel + +dmn.plugin.defineElement({ + name: "KPS Panel", + maxInstances: 1, + + contextMenu: { + create: "Create KPS Panel", + delete: "Delete KPS Panel", + items: [ + { + label: "Reset Stats", + onClick: ({ actions }) => actions.reset(), + }, + ], + }, + + settings: { + showGraph: { type: "boolean", default: true, label: "Show Graph" }, + textColor: { type: "color", default: "#FFFFFF", label: "Text Color" }, + graphColor: { + type: "color", + default: "#86EFAC", + label: "Graph Color", + visible: (s) => s.showGraph, + }, + }, + + previewState: { + kps: 12, + max: 20, + history: [5, 8, 12, 15, 10, 12], + }, + + template: (state, settings, { html }) => html` +
+
+ ${state.kps ?? 0} + KPS +
+ ${settings.showGraph + ? html` +
+ ${(state.history ?? []).map((v) => { + const height = state.max ? (v / state.max) * 100 : 0; + return html` +
+ `; + })} +
+ ` + : ""} +
+ `, + + onMount: ({ setState, expose, onHook }) => { + const timestamps = []; + let max = 0; + const historySize = 20; + const history = []; + + onHook("key", ({ state }) => { + if (state === "DOWN") { + timestamps.push(Date.now()); + } + }); + + const interval = setInterval(() => { + const now = Date.now(); + // Keep only timestamps within the last second + while (timestamps.length && timestamps[0] < now - 1000) { + timestamps.shift(); + } + + const kps = timestamps.length; + max = Math.max(max, kps); + + history.push(kps); + if (history.length > historySize) history.shift(); + + setState({ kps, max, history: [...history] }); + }, 50); + + expose({ + reset: () => { + timestamps.length = 0; + history.length = 0; + max = 0; + setState({ kps: 0, max: 0, history: [] }); + }, + }); + + return () => clearInterval(interval); + }, +}); +``` diff --git a/docs/content/en/guide/installation/page.mdx b/docs/content/en/guide/installation/page.mdx index 2643e750..49651d73 100644 --- a/docs/content/en/guide/installation/page.mdx +++ b/docs/content/en/guide/installation/page.mdx @@ -9,7 +9,7 @@ description: How to download, install, and run DM Note DM Note can be downloaded from GitHub Releases. -1. Download the latest version from the [GitHub Releases page](https://github.com/lee-sihun/DmNote/releases). +1. Download the latest version from the [GitHub Releases page](https://github.com/DmNote-App/DmNote/releases). 2. Extract the downloaded ZIP file to your desired location. 3. Run the `DM Note.exe` file. @@ -39,7 +39,7 @@ When you first run the program, two windows will appear. Program settings are automatically saved to the following path. -``` +```text %appdata%/com.dmnote.desktop/store.json ``` diff --git a/docs/content/en/guide/settings/page.mdx b/docs/content/en/guide/settings/page.mdx index 7caab6c0..3331b11e 100644 --- a/docs/content/en/guide/settings/page.mdx +++ b/docs/content/en/guide/settings/page.mdx @@ -39,6 +39,13 @@ When enabled, the overlay window ignores mouse events (click-through) allowing y When locked, you cannot directly interact with the overlay. +### Detaching the Properties Panel + +Use the detach button at the top of the properties panel to move it into a separate window at any time. Key, note, and counter editing as well as layer management work the same in the detached window. + +- Use the X button on the detached window or the reattach button at the top of the panel to return it inline. +- While detached, picking a gradient color anchor on the canvas is limited. + ## Graphics Settings ### Rendering Option @@ -70,9 +77,16 @@ Select the reference point when resizing the overlay window: Load a custom CSS file to customize key and counter styles. -1. Click the **Select CSS File** button. -2. Select your CSS file. -3. Styles are applied immediately. +1. Click the **Manage CSS** button to open the custom CSS panel. +2. Turn on the **Enable** toggle at the top of the panel. +3. Click **Import CSS File** and select your CSS file (`.css`, up to 1 MiB). +4. Styles are applied immediately. + +The list in the panel keeps up to 10 previously imported CSS files. + +- Click the **Apply** button on an entry to switch to that file instantly, without a file dialog. +- Right-click an entry and choose **Remove from list** (the file itself is not deleted). +- Entries that can no longer be applied show a badge: **Missing** (moved or deleted), **Unusable** (not a regular `.css` file anymore), or **Too large** (over 1 MiB). For detailed CSS styling information, see the [Custom CSS @@ -83,9 +97,9 @@ Load a custom CSS file to customize key and counter styles. Load JavaScript plugins to extend program functionality. -1. Turn on the **Enable JS Plugins** toggle. -2. Click the **Manage Plugins** button. -3. Click **Add JS Plugin** to select a file. +1. Click the **Manage Plugins** button to open the panel. +2. Turn on the **Enable** toggle at the top of the panel. +3. Click **Add Plugins** to select a file. For detailed plugin development information, see [Getting diff --git a/docs/content/en/guide/tips/page.mdx b/docs/content/en/guide/tips/page.mdx index 597723eb..85c44767 100644 --- a/docs/content/en/guide/tips/page.mdx +++ b/docs/content/en/guide/tips/page.mdx @@ -49,6 +49,14 @@ To capture both game and overlay at once: - Add the overlay with **Window Capture**. - Place the overlay source above the game source. +### Using a Browser Source with OBS Mode + +Turning on **OBS Mode** in settings lets you show the overlay as a browser source on the same network. Use **Copy URL** to get the address and paste it into an OBS browser source. + + + This share URL is not just a view link. Anyone on your network who has it can subscribe to your key input stream and modify plugin storage, so only share it on trusted networks. If you suspect it leaked, regenerate the session token to invalidate the old URL. + + ## Performance Optimization ### Graphics Troubleshooting @@ -103,7 +111,7 @@ Open Developer Tools with `Ctrl+Shift+I` to inspect elements and test CSS. Regularly backup important settings: -``` +```text %appdata%/com.dmnote.desktop/ ``` diff --git a/docs/content/en/settings/page.mdx b/docs/content/en/settings/page.mdx index add01094..1ee54ca8 100644 --- a/docs/content/en/settings/page.mdx +++ b/docs/content/en/settings/page.mdx @@ -22,6 +22,10 @@ description: Plugin settings management with defineSettings const pluginSettings = dmn.plugin.defineSettings({ settings: { + connectionSection: { + type: "section", + label: "Connection", + }, apiKey: { type: "string", default: "", @@ -36,27 +40,26 @@ const pluginSettings = dmn.plugin.defineSettings({ default: "dark", label: "Theme", }, - sectionDivider: { - type: "divider", + behaviorSection: { + type: "section", + label: "Behavior", }, enabled: { type: "boolean", default: true, label: "Enabled", }, + // Conditional visibility — static boolean or function, shown only while enabled is true + advancedOption: { + type: "number", + default: 5, + label: "Advanced Option", + visible: (settings) => settings.enabled, + }, }, }); -// `type: "divider"` adds only a divider in settings panel/modal. - -// Conditional visibility — use visible property to dynamically show/hide items -// Supports both static boolean and function -advancedOption: { - type: "number", - default: 5, - label: "Advanced Option", - visible: (settings) => settings.enabled, // Only shown when enabled is true -}, +// section starts a new card. (The legacy divider type was removed — existing divider entries are ignored.) // Get settings values const current = pluginSettings.get(); @@ -72,6 +75,14 @@ if (confirmed) { } ``` +`defineSettings` uses the same section contract as `defineElement`: a section starts +a new card, its optional label appears above the card, and its key is excluded from +stored/default values and callbacks. `section.visible` controls the entire group. +Sections with no renderable value settings are hidden entirely; only when no value +setting is renderable anywhere does the settings UI show its standard empty state. +Panel and modal modes have identical semantics, including fail-closed visibility +evaluation. + ## API Reference ### defineSettings(definition) @@ -115,7 +126,7 @@ Both detect settings changes but have different purposes: ```javascript const settings = dmn.plugin.defineSettings({ - settings: { apiKey: { type: "string", default: "" } }, + settings: { apiKey: { type: "string", default: "", label: "API Key" } }, // onChange: Always executes (cannot unsubscribe) onChange: (newSettings, oldSettings) => { @@ -205,7 +216,144 @@ dmn.plugin.defineElement({ // Instance-specific settings settings: { showGraph: { type: "boolean", default: true, label: "Show Graph" }, + graphColor: { + type: "color", + default: "#86EFAC", + label: "Graph Color", + visible: (s) => s.showGraph, + }, + }, + + template: (state, instanceSettings, { html }) => { + const global = globalSettings.get(); + return html` +
KPS: ${state.kps ?? 0}
+ `; + }, + + onMount: ({ setState, onHook }) => { + const global = globalSettings.get(); + let count = 0; + + onHook("key", ({ state }) => { + if (state === "DOWN") count++; + }); + + const interval = setInterval(() => { + setState({ kps: count }); + count = 0; + }, global.refreshRate); + + return () => clearInterval(interval); }, - // ... }); ``` + +### Adding Settings to the Grid Menu + +You can also add a settings menu without any panel: + +```javascript +// @id settings-only-plugin + +const pluginSettings = dmn.plugin.defineSettings({ + settings: { + volume: { type: "number", default: 50, min: 0, max: 100, label: "Volume" }, + notifications: { type: "boolean", default: true, label: "Notifications" }, + }, +}); + +// Add to the right-click menu on empty grid space +dmn.ui.contextMenu.addGridMenuItem({ + id: "my-plugin-settings", + label: "Plugin Settings", + onClick: () => pluginSettings.open(), +}); + +dmn.plugin.registerCleanup(() => { + dmn.ui.contextMenu.clearMyMenuItems(); +}); +``` + +### Reacting to Settings Changes + +```javascript +// @id data-fetcher + +const fetcherSettings = dmn.plugin.defineSettings({ + settings: { + apiEndpoint: { + type: "string", + default: "https://api.example.com", + label: "API Endpoint", + }, + refreshInterval: { + type: "number", + default: 5000, + min: 1000, + max: 60000, + label: "Refresh Interval (ms)", + }, + autoRefresh: { + type: "boolean", + default: true, + label: "Auto Refresh", + }, + }, +}); + +let fetchInterval = null; + +function startFetching() { + const { refreshInterval, autoRefresh, apiEndpoint } = fetcherSettings.get(); + + if (fetchInterval) { + clearInterval(fetchInterval); + fetchInterval = null; + } + + if (!autoRefresh) return; + + fetchInterval = setInterval(async () => { + const response = await fetch(apiEndpoint); + const data = await response.json(); + console.log("Data:", data); + }, refreshInterval); +} + +// Restart the interval when settings change +fetcherSettings.subscribe((newSettings, oldSettings) => { + if ( + newSettings.apiEndpoint !== oldSettings.apiEndpoint || + newSettings.refreshInterval !== oldSettings.refreshInterval || + newSettings.autoRefresh !== oldSettings.autoRefresh + ) { + startFetching(); + } +}); + +startFetching(); + +dmn.plugin.registerCleanup(() => { + if (fetchInterval) clearInterval(fetchInterval); +}); +``` + +## Comparison with defineElement's onSettingsChange + +| Feature | `defineElement` | `defineSettings` | +| ------------------------ | ---------------------------- | --------------------------------------- | +| Settings change handling | `onSettingsChange(callback)` | `onChange` + `subscribe()` | +| Unsubscribing | Automatic (on unmount) | Manual, via `subscribe()` return value | +| Where to use | Inside `onMount` only | Anywhere | +| Target | Instance-specific settings | Global/standalone settings | + +## Automatic Behavior + +| Feature | Description | +| ------------------------------ | -------------------------------------------------------------- | +| **Automatic UI generation** | Property panel/modal generated from the settings schema | +| **Automatic storage handling** | Saved to and restored from `plugin.storage` | +| **i18n support** | Integrates with messages | +| **Per-type components** | boolean→checkbox, color→color picker, and so on | +| **Automatic panel sync** | All panels of the same plugin re-render when settings change | diff --git a/docs/content/en/template-syntax/page.mdx b/docs/content/en/template-syntax/page.mdx index d5aea175..508e92df 100644 --- a/docs/content/en/template-syntax/page.mdx +++ b/docs/content/en/template-syntax/page.mdx @@ -12,11 +12,11 @@ It allows intuitive writing close to standard HTML syntax. ### Value Interpolation -```javascript +```javascript fragment=object-members template: (state, settings, { html }) => html`
Current value: ${state.value}
Colored text
-`; +`, ``` @@ -112,7 +112,7 @@ html` ### Inline Styles -```javascript +```javascript fragment=object-members template: (state, settings, { html }) => html`
${state.value}
KPS
-`; +`, ``` ## Practical Example ### Stats Panel -```javascript +```javascript fragment=object-members template: (state, settings, { html }) => html`