diff --git a/.gitignore b/.gitignore index ad1d5731..f2db2e99 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ app/renderer/documentation/ *.AppImage AppImage .DS_Store +/package-lock.json +PROJECT_STATE.md +SOURCE_MATCHING.md +SOURCE_MATCHING.txt diff --git a/BUILD_DEB.sh b/BUILD_DEB.sh new file mode 100755 index 00000000..cec721c2 --- /dev/null +++ b/BUILD_DEB.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env sh +set -eu +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +APP_DIR=${INKY_LINUX_APP_DIR:-"$ROOT_DIR/Inky-linux-x64"} +REVISION=${INKY_DEB_REVISION:-77} +case "$REVISION" in ''|*[!0-9]*) echo 'Invalid Debian revision' >&2; exit 1;; esac +VERSION=$(node -p 'require(process.argv[1]).version' "$ROOT_DIR/app/package.json")-$REVISION +OUTPUT_DIR="$ROOT_DIR/ReleaseUpload" +OUTPUT_FILE="$OUTPUT_DIR/inky_${VERSION}_amd64.deb" +command -v dpkg-deb >/dev/null +command -v python3 >/dev/null +test -f "$APP_DIR/resources/app/package.json" +test ! -e "$APP_DIR/resources/app.asar" +STAGE=$(mktemp -d "${TMPDIR:-/tmp}/inky-deb.XXXXXX") +trap 'rm -rf "$STAGE"' EXIT HUP INT TERM +mkdir -p "$STAGE/opt/inky" "$STAGE/usr/bin" "$STAGE/usr/share/applications" "$STAGE/usr/share/icons/hicolor/256x256/apps" "$STAGE/DEBIAN" "$OUTPUT_DIR" +cp -a "$APP_DIR/." "$STAGE/opt/inky/" +install -m 644 "$ROOT_DIR/resources/com.inkle.inky.desktop" "$STAGE/usr/share/applications/inky.desktop" +install -m 644 "$ROOT_DIR/app/renderer/about/icon-round.png" "$STAGE/usr/share/icons/hicolor/256x256/apps/inky.png" +ln -s /opt/inky/Inky "$STAGE/usr/bin/inky" +# Include ALL parent directories, especially /opt: root-only staging permissions +# must never be propagated to a user's installation. +find "$STAGE" -type d -exec chmod 755 {} + +find "$STAGE" -type f -exec chmod a+r {} + +chmod 755 "$STAGE/opt/inky/Inky" +chmod 755 "$STAGE/opt/inky/resources/app/main-process/ink/inklecate_linux" +chmod 755 "$STAGE/opt/inky/resources/app/main-process/ink/inkjs-compatible/inklecate_linux" +chmod 4755 "$STAGE/opt/inky/chrome-sandbox" +python3 "$ROOT_DIR/build/application_manifest.py" "$STAGE/opt/inky/resources" +SIZE=$(du -sk "$STAGE" | cut -f1) +sed -e "s/@VERSION@/$VERSION/" -e "s/@SIZE@/$SIZE/" "$ROOT_DIR/build/debian-control.in" > "$STAGE/DEBIAN/control" +chmod 644 "$STAGE/DEBIAN/control" +dpkg-deb --build --root-owner-group "$STAGE" "$OUTPUT_FILE" +python3 "$ROOT_DIR/build/verify_deb.py" "$OUTPUT_FILE" +printf 'Created %s\n' "$OUTPUT_FILE" diff --git a/BUILD_FOR_LINUX.sh b/BUILD_FOR_LINUX.sh index 72f7b69b..160e1276 100755 --- a/BUILD_FOR_LINUX.sh +++ b/BUILD_FOR_LINUX.sh @@ -24,11 +24,14 @@ zip -r ReleaseUpload/Inky_linux.zip Inky-linux-x64 mkdir -p AppImage/opt/inky mkdir -p AppImage/usr/share/pixmaps -cp resources/AppRun AppImage/ +install -m 755 resources/AppRun AppImage/AppRun cp resources/com.inkle.inky.desktop AppImage/ -cp resources/Icon1024.png AppImage/inky.png -cp resources/Icon1024.png AppImage/usr/share/pixmaps/inky.png +cp app/renderer/about/icon-round.png AppImage/inky.png +cp app/renderer/about/icon-round.png AppImage/usr/share/pixmaps/inky.png cp -r Inky-linux-x64/* AppImage/opt/inky/ #Build AppImage File ready for upload ARCH=x86_64 ./build/appimagetool-x86_64.AppImage -n AppImage ReleaseUpload/Inky.AppImage + +#Build Debian package with a system-wide application launcher +./BUILD_DEB.sh diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..ab722987 --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +MIT License +Copyright (c) 2016 inkle Ltd. +Copyright (c) 2026 Kernel Trace (核追跡) (fork changes) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 4fcbc0e3..212ff542 100644 --- a/README.md +++ b/README.md @@ -1,174 +1,194 @@ -![](resources/icon-small.jpg) +
-# Inky +Inky application icon -**Inky** is an editor for [ink](http://www.inklestudios.com/ink), inkle's markup language for writing interactive narrative in games, as used in [80 Days](http://www.inklestudios.com/80days). It's an IDE (integrated development environment), because it gives you a single app that lets you play in the editor as you write, and fix any bugs in your code. +

Inky

-![](resources/screenshot.gif) +

Write with ink. Bring your stories to life.

-## Features +

An independent community fork of inkle's Inky, extending the desktop editor for ink with visual story presentation, a live player, multimedia scenes, and web export.

-- **Play as you write**: The play pane remembers the choices that you made, so when Inky recompiles, it fast-forwards to the last point you were at in the flow. -- **Syntax highlighting** -- **As-you-type error highlighting**. Inky is constantly compiling, allowing you to fix errors early. -- **Issue browser**: Lists errors, warnings and TODOs in your **ink**, and allows you to jump to the exact line number and file in the source. -- **Jump to definition**: Divert targets (like `-> theStreet`) are hyperlinked can be followed by alt-clicking. -- **Support multi-file projects**: - Inky automatically infers your story's structure from the `INCLUDE` lines, meaning that there's no need for an additional project file. To create a new include file, simply type `INCLUDE yourfile.ink` where you want to include it. -- **Export to JSON**: Although this isn't necessary if you're using the [ink-Unity-integration plugin](https://assetstore.unity.com/packages/tools/integration/ink-unity-integration-60055), Inky allows you to export to ink's compiled JSON format, which is especially useful in other ink runtime implementations, such as [inkjs](https://github.com/y-lohse/inkjs), for running **ink** on the web. -- **File watching**: Modern text editors, including Inky, watch for changes to files on disk, so that if you change them it reflects those changes. This is especially helpful if you keep your **ink** in source control. +

+ Based on Inky 0.15.2 + Community revision r109 + MIT License +

-## Project status + + Inky's default project, The Borrowed Star, in the editor beside its title screen + -Inky has been used extensively on multiple projects by different developers. Nevertheless, it isn't as robust or feature complete as many other text editors you may have used, since it's specialist software made by game developers in their spare time. +

The first-open experience: The Borrowed Star, ready to read, explore, and edit.

-The informal [TODO.md](TODO.md) lists some missing features and known issues. If you want to discuss one, or request a new fix or feature, please [create a github issue](http://www.github.com/inkle/inky/issues). +
-To keep up to date with the latest news about ink [sign up for the mailing list](http://www.inklestudios.com/ink#signup). +> **An independent fork.** Inky and the ink language were created by [inkle](https://www.inklestudios.com/ink/). This project builds on their work; it is not affiliated with or endorsed by inkle Ltd. Original Ink syntax and branching logic remain at its core. -## Download +## Write a story. See it come alive. -### Mac, Windows and Linux +Write in Ink on the left. Follow the story on the right. As you revise dialogue, add branches, or change presentation settings, the live Preview helps you understand what the reader will experience. -[Download the latest release](http://www.github.com/inkle/inky/releases/latest) + + Inky's Visual settings beside a live passage with title, narrative text and interactive choices + -## Project settings file +*A passage with a title, narrative text, and choices, shown alongside the Visual settings that shape its appearance.* -**Warning: For the technically inclined - you need to understand what a JSON file is to do the following!** +Inky keeps the writing workflow familiar: syntax highlighting, autocomplete, reusable snippets, issue reporting, a project Explorer, and interactive story navigation. This fork adds control over the way that writing reaches the screen. -To customise Inky settings for your specific ink project, create a JSON file with the same name as your main ink file, except with a `.settings.json` extension. For example, if your main ink file is called `my_great_story.ink`, then name your JSON file `my_great_story.settings.json`. +## Shape the reader's experience -Here is an example settings file: +The **Visual** tab makes presentation adjustable without requiring custom CSS. Settings are stored with the project in `game.yaml`; **Advanced** provides access to the underlying configuration. - { - "customInkSnippets": [ - { - "name": "Heaven's Vault", - "submenu": [ - { - "name": "Camera", - "ink": ">>> CAMERA: Wide shot" - }, - { - "separator": true - }, - { - "name": "Walk", - "ink": ">>> WALK: TheInscription" - }, - { - "name": "More snippets", - "submenu": [ - { - "name": "A snippet in a submenu", - "ink": "This snippet of ink came from a submenu." - }, - ] - } - ] - } - ], +| Presentation | Reader experience | +| :-- | :-- | +| **Classic, Night, Sepia, and Terminal** themes | Distinct looks, with optional light/dark switching | +| **Fonts, sizes, spacing, and alignment** | Readable layouts in a portrait **9:16** frame | +| **Title Screen** | Cover image, title, author, and a title-size control in pixels | +| **Sound controls** | Adjustable master, music, and effects volume | +| **Story endings** | Optional **The End** marker and **Play Again** button | - "instructionPrefix": ">>>" - } +The Title Screen and the reading passages are separate: a scene background does not overwrite the cover. Stories can be kept minimal or given a more illustrated identity. -* `customInkSnippets` - this array allows your to add your own project-specific ink snippets to the Ink menu. There are three types of item you can add to the array: - * **An ink snippet**: Requires `name` for the name of the menu item and `ink` for the snippet of ink it will insert into the editor. - * **A separator**: Use `{"separator": true}` to add a horizonal line separator into the menu at this point. - * **A sub-menu**: To nest more snippets into a sub-menu, use `name` for the sub-menu name, and then `submenu` with another array in the same format. +### Two ways to present Ink +**Traditional Mode** retains the continuous reading style of the original Inky. **Screen Mode** presents individual passages as clean, self-contained screens while leaving Ink variables, choices, and diverts intact. -* `instructionPrefix` - A common convention is to use a particular text format in ink to instruct the game to perform certain actions rather than presenting the text verbatim to players. +Screen Mode is optional. Enable it under **Visual → Story presentation** and introduce a screen with `#screen`: - For example, at inkle we would write something like `>>> CAMERA: BigSwoop` in ink. The `>>>` isn't directly built into ink, and this entire text is simply passed through as plain text. But we have custom game code to interpret it and turn it into an action that takes place in-game. To help support this within Inky, you can define an *instructionPrefix*, if you have a particular consistent way of writing these actions. - - When Inky sees this, it will highlight the line of text both in the editor and player views, so that you can clearly see that it's not part of the game text itself. +```ink +=== bridge === +#screen +#title: Voices in the Snow +The old bridge groans beneath the weight of winter. -## Implementation details ++ [Climb toward the star.] -> hill +``` -Inky is built using: +### Images, backgrounds, music, and sound -* [Electron](http://electron.atom.io/), a framework by GitHub to build cross-platform Desktop app using HTML, CSS and JavaScript. -* [Ace](https://ace.c9.io/#nav=about), a full-featured code editor built for the web. -* [Photon](http://photonkit.com/), for some of the components. However, the dependency could probably be removed, since its only used for small portions of the CSS. +Scene tags let a writer give each passage its own look and atmosphere: -Inky includes a copy of **inklecate**, the command line **ink** compiler. +```ink +=== bridge === +#screen +#bg: image/background/reading-texture.png +#image: image/scene/bridge-bell.jpg +#music: audio/music/bridge-mist.mp3 +#sound: audio/sfx/bell.wav +#title: Voices in the Snow -## Help develop Inky! +The old bridge groans beneath the weight of winter. -Take a look at the [issues page](https://github.com/inkle/inky/issues) for an issue with a "help wanted" label. We try to provide some basic instructions on how to get started with the development of the feature whenever we add the label. ++ [Climb toward the star.] -> hill +``` -To build the project: +`#image` places artwork above the passage; `#bg` sets an image behind the scene content. `#title` introduces the passage title, while `#music` and `#sound` control audio. Resource folders are conventions rather than restrictions: project-relative paths are supported. -* Install [node.js](https://nodejs.org/en/) if you don't already have it -* Clone the repo -* On Mac, double-click the `INSTALL_AND_RUN.command` script. On Windows, open Powershell, cd into the app directory, and type `npm install`, then `npm start`. -* For subsequent runs, if no npm packages have changed, you can run the `RUN.command` script on Mac, or type `npm start` in the shell (on Windows). +## Meet Before Dawn -### Linux +**Before Dawn** is this fork's official showcase: a short fantasy adventure designed to demonstrate what an Ink story can become when writing, artwork, sound, and reader presentation work together. -Tested on a fresh **Ubuntu 16.04 LTS** VM installation (_equivalent processes should work for other distributions_) +It is a **real, editable project**, included with the source. Open it through **Ink → Full stories** to explore its branching narrative, scene assets, choices, and three endings. -* Install build tools + + The Before Dawn project open in Inky: Ink source, audio files, images, and illustrated Title Screen + -`sudo apt-get install -y dkms build-essential linux-headers-generic linux-headers-$(uname -r)` +*A single Before Dawn project in the Explorer, with its Ink source, original assets, and live Title Screen in the Preview.* -* Pre-requisites +### Start with a cover -`sudo apt install git` +
-`sudo apt install curl` + + Before Dawn's illustrated Title Screen with the story title, author credit, Start and Load + -* Install node and npm +

The actual Title Screen in the Inky Preview: cover, title, author credit, Start, and Load.

-`curl -sL https://deb.nodesource.com/setup_8.x | sudo -E bash -` +
-`sudo apt-get install -y nodejs` +### Give each passage its own atmosphere -* Install mono as per http://www.mono-project.com/download/stable/#download-lin +A scene can combine **top artwork**, **background texture**, **scene title**, **narrative text**, and **choices**. The illustration can change between passages while the reading frame remains consistent. -`sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys 3FA7E0328081BFF6A14DA29AA6A19B38D3D831EF` +
-`echo "deb http://download.mono-project.com/repo/ubuntu stable-xenial main" | sudo tee /etc/apt/sources.list.d/mono-official-stable.list` +The Old Forge captured in Inky: a passage with top illustration, dark textured background, title, narrative and branching choices +Voices in the Snow captured in Inky: another illustrated passage with its own image, title, story text and interactive choices -`sudo apt-get update` +

The Old Forge · Voices in the Snow — two actual passages captured from Before Dawn in the Inky Preview.

-`sudo apt-get install mono-complete` +
-* Clone the inky repo +Music and sound effects can accompany the scenes without being written into the narrative itself. The showcase includes both, alongside its cover, scene images, and alternate outcomes. -`git clone https://github.com/inkle/inky.git` +Explore the [story source](app/resources/showcase/before-dawn/Before%20Dawn.ink) and [project settings](app/resources/showcase/before-dawn/game.yaml) to see how the pieces fit together. **Elias Wren** is a fictional author credit for the showcase. -* Test inklecate_win with mono (_should output usage info_) +## Built on the original Inky -`mono app/main-process/ink/inklecate_win.exe` +The original editor's full-story examples remain available as a tribute to the work that came before this fork: -* Install and run inky +- **Crime Scene** — from *Writing with Ink* +- **Swindlestones** — from *Sorcery!* +- **Pontoon Game** — from *Overboard!* +- **The Intercept** -`./INSTALL_AND_RUN.command` +In **Ink → Full stories**, these appear together **above a divider**, with **Before Dawn** displayed separately beneath it. Opening a story creates an editable project, with compatibility adjustments for this fork's reader where needed. -* For subsequent runs, if no npm packages have changed, launch inky as below (otherwise re-run previous step): +**The Borrowed Star**, meanwhile, is the starter story presented when Inky is opened without an existing project. It is not part of the original Inky collection or the illustrated showcase. -`./RUN.command` +## Take your story to the web -### Translation +Use **File → Export for web** to produce a browser-playable story with its Ink content, reader interface, styles, and referenced local assets. The export supports scene presentation, Title Screens, and reader controls, including save/load and configurable endings. -Translation files are located under `app/main-process/i18n/`. -If a particular locale file is missing (or if it's missing some keys), you can generate it with the following command: `cd app && npm run generate-locale -- ./main-process/i18n/`. +Preview and Web Export are developed to behave consistently. Before publishing, test the exported `index.html` and its assets in a browser. -## License +## Download and run -**Inky** and **ink** are released under the MIT license. Although we don't require attribution, we'd love to know if you decide to use **ink** a project! Let us know on [Twitter](http://www.twitter.com/inkleStudios) or [by email](mailto:info@inklestudios.com). +The community **r109** build targets **Linux x86-64**. Download the `.deb` or AppImage from the project's [GitHub Releases](https://github.com/glassflare/inky/releases) when this revision is published. -### The MIT License (MIT) -Copyright (c) 2016 inkle Ltd. +**Debian / Ubuntu** -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +```bash +sudo apt install ./inky-r109-upgrade-compatible.deb +``` -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +**AppImage** -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +```bash +chmod +x Inky-0.15.2-109-x86_64.AppImage +./Inky-0.15.2-109-x86_64.AppImage +``` -- +Some Linux distributions may require FUSE compatibility libraries for AppImage. The source tree also includes scripts for other platforms; these downloads are Linux builds. -*Inky is named after a black cat based in Cambridge, UK.* +### Build from source + +You'll need Node.js, npm, Git, and the system libraries required by Electron. + +```bash +git clone https://github.com/glassflare/inky.git +cd inky/app +npm ci +npm start +``` + +To run the project's tests from `app/`: + +```bash +npm test +npm run test:web-export +``` + +The underlying application version remains **0.15.2**; **r109** identifies this fork's community revision. + +## Contribute + +Issues and improvements are welcome through [GitHub Issues](https://github.com/glassflare/inky/issues). When reporting a problem, include your operating system, build revision, steps to reproduce it, and whether it occurs in Preview, Web Export, or both. + +## Credits and license + +The original [Inky](https://github.com/inkle/inky) editor and [ink](https://github.com/inkle/ink) language are by **inkle Ltd**. This independent fork preserves the original story examples with their historical credits. Distributed under the [MIT License](LICENSE), subject to applicable upstream and third-party notices. diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 85b07d8b..00000000 --- a/TODO.md +++ /dev/null @@ -1,66 +0,0 @@ -# TODO - -## Features and improvements - -* Some more shortcuts: - * Ctrl-(shift)-tab to switch back/forth between files (what order? order of usage or order within sidebar?) - * Back/forward shortcuts (mirroring back/forward buttons) cmd-opt-left/right? - * Follow symbol under cursor - cmd-opt-return? - -* Find in project - * Implement across multiple files - * Replace UI for single files while you're at it? - * editor.commands.removeCommand('find'); - -* Go to symbol in project - -* Removal and renaming of includes (and renaming of main ink?) - * Contextual menu on includes (Rename, Delete) - * Automatically find/replace matching include line - * Mac-style pressing return with a file selected to rename it? Double click to rename? - * File -> Rename current file -* Improve behaviour for renaming files in finder - * Currently file watcher removes then adds, and this causes both the newly renamed file and the old one to appear. The old one appears because it's still being included from somewhere, similar to when you type a new INCLUDE line. Perhaps add a little subtitle to the filename to say "Included from xyz.ink"? - -* Proper hierarchy view for includes rather than currently single-level groupings? -* Drag/drop includes between groups? (tricky!) -* Highlight files in nav that have errors -* Add filenames to issue browser (as headings, only when in multi-file and not in single active file) -* Switch to specific ink file within the current project when opening an ink file externally that's related to the current project - -* Error checking for file system integration (opening / saving files etc currently doesn't check for any errors) - -* Toolbar UI to jump to a particular path at the start of the story when playing -* Other debugging features: ability to query variables, list variables etc -* Include step back buttons on each turn chunk to rewind to a specific one - -* Ability to hide/show editor and player views - * Add large-ish left/right margins to both when very wide to give a sort of "focus" mode, esp when fullscreen - -* Pause live compilation / playing? - -* Dynamically change menu item titles to reflect current file. e.g. Save current file => Save jolly.ink - -* Get menu item enabling behaviour right - e.g. save is only available if it's currently needed - -* Ability to export a full web player, using ink.js - -* Ability to load & play a JSON file, with all editing controls hidden? - -## Engineering - -* **FIX:** - * Ace editor highlights don't get removed when switching between files (sometimes?) - * Perhaps when you fix an error then return to the file where the error was? (e.g. "-> elsewhere", new file, define elsewhere, fixes error in original file) - * `/tmp` never gets cleared out, so if you remove a file in the project (e.g. in the finder), it still compiles when it shouldn't. - * Replaying a story goes through a transition for the last turn - * jquery still fades in the last chunk even though it's a replay - * should force the view height never to get smaller despite temporary content reduction - * Cmd-D for "don't save" option - * (VERIFY) Multiple windows - can be flaky? e.g. saving v.s. compiling etc - * (DONE?) Reliability of story reloading - sometimes it fails - * (DONE?) Copies of inklecate left open sometimes - -* Change InkFile.path to always be the relative path, remove InkFile.relativePath(), and use InkFile.absolutePath() as the special case, since we always know the relative path, even before saving, and it would simplify the code paths and make things more robust. - -* Convert ad-hoc style events to be proper NodeJS EventEmitters (?) diff --git a/app/build-package.js b/app/build-package.js index 1a216e2b..556fad2b 100644 --- a/app/build-package.js +++ b/app/build-package.js @@ -151,7 +151,9 @@ async function buildPackageForPlatform(targetPlatform) { prune: true, asar: { unpackDir: 'main-process/ink' - } + }, + // Bundled example projects are kept in source; the interactive showcase ships in app/resources. + extraResource: [] }; if( targetPlatform == "mac" ) { diff --git a/app/export-for-web-template/favicon.ico b/app/export-for-web-template/favicon.ico new file mode 100644 index 00000000..cdae717c Binary files /dev/null and b/app/export-for-web-template/favicon.ico differ diff --git a/app/export-for-web-template/index.html b/app/export-for-web-template/index.html index 2ba6ad8c..8a636d69 100644 --- a/app/export-for-web-template/index.html +++ b/app/export-for-web-template/index.html @@ -1,4 +1,4 @@ - + @@ -7,32 +7,65 @@ + + - +
-

WRITTEN IN INK

+
-
- restart - save - load - theme -
+
+
+
+ + +
+
+
-
-
-

##STORY TITLE##

- -
+ ##TITLE-SCREEN## + + + + +
+ + + + + diff --git a/app/export-for-web-template/main.js b/app/export-for-web-template/main.js index f46a3388..b9236999 100644 --- a/app/export-for-web-template/main.js +++ b/app/export-for-web-template/main.js @@ -1,128 +1,567 @@ (function(storyContent) { + var ChoiceText = window.InkyChoiceText; + if( !ChoiceText ) throw new Error("The choice-text helper is missing from this export."); + var ScreenMode = window.InkyScreenMode; + if( !ScreenMode ) throw new Error("The Screen Mode helper is missing from this export."); + var presentation = document.body.getAttribute("data-presentation") || "traditional"; + var EndingMarker = window.InkyEndingMarker; + if( !EndingMarker ) throw new Error("The ending-marker helper is missing from this export."); + var endingEnabled = document.body.getAttribute("data-ending-show") !== "off"; + var endingText = document.body.getAttribute("data-ending-text") || EndingMarker.DEFAULT_TEXT; + var playAgainEnabled = document.body.getAttribute("data-play-again-show") !== "off"; + var playAgainText = document.body.getAttribute("data-play-again-text") || EndingMarker.REPLAY_LABEL; + + function storyFingerprint(content) { + var serialized = JSON.stringify(content); + var first = 2166136261; + var second = 0x9e3779b9; + for( var i = 0; i < serialized.length; i++ ) { + var code = serialized.charCodeAt(i); + first = Math.imul(first ^ code, 16777619) >>> 0; + second = Math.imul(second ^ code, 2246822519) >>> 0; + } + return first.toString(16).padStart(8, "0") + second.toString(16).padStart(8, "0"); + } + + // Keep saves from separate stories and revisions isolated on the same origin. + var saveKey = "save-state:" + location.pathname.replace(/index\.html$/i, "") + ":" + storyFingerprint(storyContent); + // Story text is plain text: markup is shown as written, never interpreted as HTML. + function storyTextNode(text) { + // richtext.js (shared with the Preview) turns the bold/italic markers into / nodes + if( window.InkRichText ) return window.InkRichText.toNodes(text, document); + return document.createTextNode(String(text == null ? "" : text).replace(/\u27e6\/?[^\u27e6\u27e7]{0,8}\u27e7/g, "")); + } + + // Choices and scene titles are never formatted: markers left in the file are dropped, the words stay. + function plainStoryText(text) { + text = String(text == null ? "" : text); + if( window.InkRichText ) return window.InkRichText.plainText(text); + return text.replace(/\u27e6\/?[^\u27e6\u27e7]{0,8}\u27e7/g, ""); + } + + function safeStoryUrl(value, allowMailto) { + value = String(value || "").trim(); + if( !value || value.startsWith("//") ) return null; + try { + var resolved = new URL(value, location.href); + if( resolved.protocol === "http:" || resolved.protocol === "https:" ) return resolved.href; + if( resolved.protocol === "mailto:" && allowMailto && /^mailto:/i.test(value) ) return resolved.href; + if( resolved.protocol === "file:" && !/^[a-z][a-z0-9+.-]*:/i.test(value) ) return resolved.href; + } catch(error) { + return null; + } + return null; + } + + function addSafeClasses(element, values) { + values.forEach(value => String(value).split(/\s+/).forEach(className => { + if( /^[A-Za-z_][A-Za-z0-9_-]*$/.test(className) ) element.classList.add(className); + })); + } + + + // ---- Sound ------------------------------------------------------------------------- + // "# music: file.mp3" loops until another music tag ("# music: stop" ends it), + // "# sound: file.mp3" plays once. Both read files from the audio folder next to this page. + // The speaker button and the volume slider in the top bar control all of it, and the player's + // choice is remembered. + var AUDIO_EXTENSIONS = [".mp3", ".ogg", ".oga", ".wav", ".m4a", ".aac", ".flac", ".opus", ".webm"]; + + function audioUrlForFile(value) { + var name = String(value || "").trim().replace(/^["']|["']$/g, "").replace(/\\/g, "/"); + if( !name || name.charAt(0) === "/" || /[\x00-\x1f]/.test(name) ) return null; + var parts = name.split("/"); + if( parts.some(function(part) { return !part || part === "." || part === ".."; }) ) return null; + var filename = parts[parts.length - 1]; + var dot = filename.lastIndexOf("."); + if( dot <= 0 || AUDIO_EXTENSIONS.indexOf(filename.slice(dot).toLowerCase()) === -1 ) return null; + return "audio/" + parts.map(encodeURIComponent).join("/"); + } + + var audioEngine = (function() { + var enabled = document.body.getAttribute("data-audio") === "on"; + var startVolume = parseInt(document.body.getAttribute("data-volume"), 10); + if( isNaN(startVolume) ) startVolume = 50; + startVolume = Math.max(0, Math.min(100, startVolume)); + var masterVolume = parseInt(document.body.getAttribute("data-master-volume"), 10); + var musicVolume = parseInt(document.body.getAttribute("data-music-volume"), 10); + var sfxVolume = parseInt(document.body.getAttribute("data-sfx-volume"), 10); + if( isNaN(masterVolume) ) masterVolume = 100; + if( isNaN(musicVolume) ) musicVolume = 100; + if( isNaN(sfxVolume) ) sfxVolume = 100; + masterVolume = Math.max(0, Math.min(100, masterVolume)) / 100; + musicVolume = Math.max(0, Math.min(100, musicVolume)) / 100; + sfxVolume = Math.max(0, Math.min(100, sfxVolume)) / 100; + + var volume = startVolume / 100; + var muted = false; + try { + var storedVolume = window.localStorage.getItem("inky-audio-volume"); + if( storedVolume !== null && storedVolume !== "" && !isNaN(Number(storedVolume)) ) + volume = Math.max(0, Math.min(1, Number(storedVolume) / 100)); + muted = window.localStorage.getItem("inky-audio-muted") === "1"; + } catch(e) { /* storage unavailable */ } + var lastAudible = volume > 0 ? volume : (startVolume > 0 ? startVolume / 100 : 0.5); + + var tracks = []; // music that is playing, fading in or fading out + var effects = []; // active one-shot sounds + var music = null; // the track that should be playing now + var needsGesture = false; // the browser refused to start sound before a click or key press + var ticker = null; + + function level(category) { + return muted ? 0 : volume * masterVolume * (category === "music" ? musicVolume : sfxVolume); + } + function applyVolume(track) { track.el.volume = Math.max(0, Math.min(1, level("music") * track.fade)); } + function applyEffectVolume(effect) { effect.el.volume = level("sfx"); } + function applyAll() { + tracks.forEach(applyVolume); + effects.forEach(applyEffectVolume); + } + + function tick() { + var fading = false; + var trackCount = tracks.length; + tracks = tracks.filter(function(track) { + if( track.fade < track.target ) track.fade = Math.min(track.target, track.fade + 0.06); + else if( track.fade > track.target ) track.fade = Math.max(track.target, track.fade - 0.06); + applyVolume(track); + if( track.fade === 0 && track.target === 0 ) { + track.el.pause(); + track.el.removeAttribute("src"); + track.el.load(); + return false; + } + if( track.fade !== track.target ) fading = true; + return true; + }); + if( trackCount !== tracks.length ) refreshControl(); + if( !fading && ticker ) { clearInterval(ticker); ticker = null; } + } + function ensureTicker() { if( !ticker ) ticker = setInterval(tick, 40); } + + function tryPlay(track) { + var attempt = track.el.play(); + if( attempt && attempt.catch ) attempt.catch(function() { needsGesture = true; }); + } + + function setMusicUrl(url) { + if( url && music && music.url === url ) return; // already playing it + if( music ) { music.target = 0; ensureTicker(); } + music = null; + if( !url ) { refreshControl(); return; } + var el = new Audio(url); + el.loop = true; + el.volume = 0; + var track = { el: el, url: url, fade: 0, target: 1 }; + el.addEventListener("error", function() { + console.warn("Sound file not found or not playable:", url); + track.target = 0; + track.fade = 0; + if( music === track ) music = null; + // Stop tracking a file the player cannot play. + var index = tracks.indexOf(track); + if( index !== -1 ) tracks.splice(index, 1); + refreshControl(); + }); + tracks.push(track); + music = track; + refreshControl(); + tryPlay(track); + ensureTicker(); + } + + function playSoundUrl(url) { + // Play even when the reader has muted the volume. The effect remains + // active (silently), so its volume control can restore the sound. + if( !url ) return; + var el = new Audio(url); + var effect = { el: el }; + el.volume = level("sfx"); + var removeEffect = function() { + var index = effects.indexOf(effect); + if( index !== -1 ) { + effects.splice(index, 1); + refreshControl(); + } + }; + effects.push(effect); + refreshControl(); + el.addEventListener("ended", removeEffect); + el.addEventListener("error", function() { + console.warn("Sound file not found or not playable:", url); + removeEffect(); + }); + var attempt = el.play(); + if( attempt && attempt.catch ) attempt.catch(removeEffect); + } + + function stopSoundEffects() { + effects.forEach(function(effect) { + effect.el.pause(); + effect.el.removeAttribute("src"); + effect.el.load(); + }); + effects = []; + refreshControl(); + } + + function onGesture() { + if( !needsGesture ) return; + needsGesture = false; + tracks.forEach(function(track) { if( track.el.paused && track.target === 1 ) tryPlay(track); }); + } + ["pointerdown", "keydown", "touchstart"].forEach(function(type) { document.addEventListener(type, onGesture, true); }); + + function persist() { + try { + window.localStorage.setItem("inky-audio-volume", String(Math.round(volume * 100))); + window.localStorage.setItem("inky-audio-muted", muted ? "1" : "0"); + } catch(e) { /* storage unavailable */ } + } + + function refreshControl() { + var control = document.getElementById("audio-control"); + var anchor = document.getElementById("audio-settings-anchor"); + var controls = document.getElementById("controls"); + // The export may contain sound files that have not played yet. They must not + // expose an inert slider until a real sound or music track is active. + var active = enabled && (tracks.length > 0 || effects.length > 0); + if( anchor ) anchor.hidden = !active; + if( control ) control.hidden = !active; + if( controls ) controls.classList.toggle("has-audio", active); + if( !control ) return; + var silent = muted || volume === 0; + control.classList.toggle("muted", silent); + var toggle = document.getElementById("audio-toggle"); + if( toggle ) toggle.setAttribute("aria-pressed", String(!silent)); + var slider = document.getElementById("audio-volume"); + if( slider ) slider.value = String(muted ? 0 : Math.round(volume * 100)); + } + + if( enabled ) { + var control = document.getElementById("audio-control"); + // Visibility is driven by the playback lifecycle, not by the presence of tags. + var slider = document.getElementById("audio-volume"); + var toggle = document.getElementById("audio-toggle"); + if( slider ) slider.addEventListener("input", function() { + volume = Math.max(0, Math.min(1, Number(slider.value) / 100)); + muted = false; + if( volume > 0 ) lastAudible = volume; + applyAll(); persist(); refreshControl(); + }); + if( toggle ) toggle.addEventListener("click", function() { + if( muted || volume === 0 ) { + muted = false; + if( volume === 0 ) volume = lastAudible; + } else { + muted = true; + } + applyAll(); persist(); refreshControl(); + }); + refreshControl(); + } + + return { + enabled: enabled, + setMusicUrl: setMusicUrl, + playSoundUrl: playSoundUrl, + stopSoundEffects: stopSoundEffects, + currentMusic: function() { return music ? music.url : ""; } + }; + })(); + + function processAudioTag(tag) { + var splitTag = splitPropertyTag(tag); + if( !splitTag ) return false; + var property = splitTag.property.trim().replace(/^#\s*/, "").toUpperCase(); + + if( property == "MUSIC" ) { + if( /^(stop|none|off)$/i.test(splitTag.val) ) audioEngine.setMusicUrl(null); + else { + var musicUrl = audioUrlForFile(splitTag.val); + if( musicUrl ) audioEngine.setMusicUrl(musicUrl); + else if( splitTag.val ) console.warn("Not a sound file name:", splitTag.val); + } + } else if( property == "SOUND" ) { + if( /^stop$/i.test(splitTag.val) ) audioEngine.stopSoundEffects(); + else { + var soundUrl = audioUrlForFile(splitTag.val); + if( soundUrl ) audioEngine.playSoundUrl(soundUrl); + else if( splitTag.val ) console.warn("Not a sound file name:", splitTag.val); + } + } else { + return false; + } + return true; + } + + // What the saved game remembers: the music that was playing when the save point was taken. + var savePointMusic = ""; + var savePointChoiceText = null; + function markSavePoint() { + savePoint = story.state.toJson(); + savePointMusic = audioEngine.currentMusic(); + savePointChoiceText = selectedChoiceText; + } + + function restoreSavedMusic() { + var url = null; + try { + var saved = window.localStorage.getItem(saveKey + ":music"); + if( saved ) url = safeStoryUrl(saved, false); + } catch(e) { /* storage unavailable */ } + audioEngine.setMusicUrl(url); + } + // Create ink story from the content using inkjs var story = new inkjs.Story(storyContent); var savePoint = ""; - let savedTheme; - let globalTagTheme; + // Alignment of the current scene, set by "# align:" together with "# title:". + var sceneAlign = null; - // Global tags - those at the top of the ink file - // We support: - // # theme: dark - // # author: Your Name + // Global alignment tags are still supported for existing Ink projects. var globalTags = story.globalTags; + var globalTagSet = new Set(globalTags || []); if( globalTags ) { for(var i=0; i 2); + } + storyContainer.addEventListener("scroll", updateMoreBelow, { passive: true }); + // A picture in the text only knows its height once it has loaded, and a different window size + // changes how much text is below the edge. + storyContainer.addEventListener("load", updateMoreBelow, true); + window.addEventListener("resize", updateMoreBelow); + new MutationObserver(updateMoreBelow).observe(storyContainer, { childList: true, subtree: true }); + // The arrow takes the reader to the end of the text. + var scrollCue = document.querySelector(".scroll-cue"); + if( scrollCue ) scrollCue.addEventListener("click", function() { + var reduceMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + storyContainer.scrollTo({ top: Math.max(0, storyContainer.scrollHeight - scrollFill()), behavior: reduceMotion ? "auto" : "smooth" }); + }); + var selectedChoiceText = null; + story.onError = function(message) { + console.error("Ink runtime issue:", message); + var errorElement = document.createElement("p"); + errorElement.className = "runtime-error"; + errorElement.setAttribute("role", "alert"); + errorElement.textContent = "Ink runtime issue: " + message; + storyContainer.appendChild(errorElement); + }; + + // Page features setup. The page uses the colors from game.yaml; when the writer allowed it, + // the story menu also lets the player switch between the light and the dark look. var hasSave = loadSavePoint(); + // A stored save enables Load, but Start always begins a fresh playthrough. + story.ResetState(); + selectedChoiceText = null; + savePointChoiceText = null; setupButtons(hasSave); + setupLookToggle(); + + // The story does not run under the title screen. "Start" is the only place where it starts, so + // the browser sees the first passage, and the sounds in it, as a result of the player's own click. + var titleScreen = document.getElementById("title-screen"); + var storyStarted = false; + var startsFromBeginning = false; + + // A reset brings the title screen back: the story stays reset behind it, and "Start" is the + // only way in again, as on a freshly opened page. + function showTitleScreen() { + // A scene background belongs to the story passage, never to the title screen. + clearStoryBackground(); + storyStarted = false; + selectedChoiceText = null; + savePointChoiceText = null; + startsFromBeginning = true; + if( titleScreen ) titleScreen.hidden = false; + storyFrame.classList.add("title-screen-active"); + storyContainer.hidden = true; + clearChoices(); + } - // Set initial save point - savePoint = story.state.toJson(); + function startStory(loadFromSave) { + if( storyStarted ) return; + if( loadFromSave === true ) { + try { + var state = window.localStorage.getItem(saveKey); + if( !state ) return; + story.state.LoadJson(state); + selectedChoiceText = ChoiceText.loadSelectedChoiceText(window.localStorage, saveKey); + savePointChoiceText = selectedChoiceText; + } catch(e) { + console.warn("Discarding an incompatible story save", e); + discardSavePoint(); + hasSave = false; + var load = document.getElementById("title-load"); + if( load ) load.disabled = true; + return; + } + } else { + story.ResetState(); + selectedChoiceText = null; + savePointChoiceText = null; + } + storyStarted = true; + if( titleScreen ) titleScreen.hidden = true; + storyFrame.classList.remove("title-screen-active"); + storyContainer.hidden = false; + if( pager ) pager.refresh(); + + // A saved game goes on from where it was saved, with its music. After a reset the story + // starts from its beginning instead. + startsFromBeginning = false; + if( loadFromSave === true ) restoreSavedMusic(); + + // Set initial save point + markSavePoint(); + + // Kick off the start of the story! + continueStory(); + } - // Kick off the start of the story! - continueStory(true); + var startButton = titleScreen && titleScreen.querySelector(".choice-button"); + if( startButton ) startButton.addEventListener("click", function() { startStory(false); }); + else startStory(false); + var titleLoad = document.getElementById("title-load"); + if( titleLoad ) { + titleLoad.disabled = !hasSave; + titleLoad.addEventListener("click", function() { + if( !titleLoad.disabled ) startStory(true); + }); + } // Main story processing function. Each time this is called it generates // all the next content up as far as the next set of choices. - function continueStory(firstTime) { + function continueStory() { - var paragraphIndex = 0; var delay = 0.0; - // Don't over-scroll past new content - var previousBottomEdge = firstTime ? 0 : contentBottomEdgeY(); - // Generate story text - loop through available content while(story.canContinue) { // Get ink to generate the next paragraph var paragraphText = story.Continue(); - var tags = story.currentTags; + var choiceTextResult = ChoiceText.stripSelectedChoiceText(paragraphText, selectedChoiceText); + paragraphText = choiceTextResult.text; + if( choiceTextResult.consumed ) selectedChoiceText = null; + var tags = story.currentTags || []; + // Screen Mode does not affect inkjs execution. It only replaces the + // visible page before rendering the incoming paragraph and its tags. + if( ScreenMode.startsScreen(presentation, tags) ) { + if( storyContainer.querySelector("p, .scene-title, img") ) + console.warn("Screen Mode: text before # screen in the same turn is not shown. Move it to the destination screen or add a choice."); + clearScene(); + } + var sceneTitle = null; + var pendingImages = []; // Any special tags included with this line var customClasses = []; + var paragraphAlign = null; for(var i=0; i { - console.log(`scrollingto ${previousBottomEdge}`) - scrollDown(previousBottomEdge) - } - - showAfter(delay, imageElement); - delay += 200.0; + var imageUrl = safeStoryUrl(splitTag.val, false); + if( imageUrl ) pendingImages.push(imageUrl); + else if( splitTag.val ) console.warn("Blocked unsupported image URL", splitTag.val); } // LINK: url else if( splitTag && splitTag.property == "LINK" ) { - window.location.href = splitTag.val; + var linkUrl = safeStoryUrl(splitTag.val, true); + if( linkUrl ) window.location.href = linkUrl; + else console.warn("Blocked unsupported story link", splitTag.val); } // LINKOPEN: url else if( splitTag && splitTag.property == "LINKOPEN" ) { - window.open(splitTag.val); + var openUrl = safeStoryUrl(splitTag.val, true); + if( openUrl ) window.open(openUrl, "_blank", "noopener,noreferrer"); + else console.warn("Blocked unsupported story link", splitTag.val); } - // BACKGROUND: src - else if( splitTag && splitTag.property == "BACKGROUND" ) { - outerScrollContainer.style.backgroundImage = 'url('+splitTag.val+')'; + // bg: src. The image is painted on the frame, behind its picture and passage. + else if( splitTag && splitTag.property == "BG" ) { + var backgroundUrl = safeStoryUrl(splitTag.val, false); + if( backgroundUrl ) { + if( storyFrame ) { + storyFrame.style.backgroundImage = "url(" + JSON.stringify(backgroundUrl) + ")"; + storyFrame.classList.add("has-background"); + } + } + else console.warn("Blocked unsupported background image", splitTag.val); } // CLASS: className @@ -130,35 +569,92 @@ customClasses.push(splitTag.val); } - // CLEAR - removes all existing content. - // RESTART - clears everything and restarts the story from the beginning - else if( tag == "CLEAR" || tag == "RESTART" ) { - removeAll("p"); - removeAll("img"); - - // Comment out this line if you want to leave the header visible when clearing + // CLEAR - removes all existing content and hides the header. + // RESTART - clears everything and restarts the story from the beginning. + else if( tag.trim().replace(/^#/, "").toUpperCase() == "CLEAR" || + tag.trim().replace(/^#/, "").toUpperCase() == "RESTART" ) { + clearScene(); setVisible(".header", false); - if( tag == "RESTART" ) { + if( tag.trim().replace(/^#/, "").toUpperCase() == "RESTART" ) { restart(); return; } } } - + + // A scene starts with "# title:"; its "# align:" tag (if any) applies + // to the whole scene. Without one the scene uses the story alignment. On any other + // paragraph "# align:" only affects that paragraph. + if( sceneTitle !== null ) + sceneAlign = paragraphAlign; + var effectiveAlign = paragraphAlign || sceneAlign; + + if( sceneTitle !== null ) { + var sceneTitleElement = document.createElement('h2'); + sceneTitleElement.className = 'scene-title'; + if( paragraphAlign ) sceneTitleElement.classList.add("align" + paragraphAlign); + sceneTitleElement.textContent = plainStoryText(sceneTitle); + storyContainer.appendChild(sceneTitleElement); + showAfter(delay, sceneTitleElement); + delay += 200.0; + } + + // Preview flushes the paragraph before processing its #image tags. + // Keep that ordering here: title, paragraph, additional inline + // images. The first image always goes in the frame's top panel. + function appendPendingImages() { + pendingImages.forEach(function(imageUrl) { + var imageElement = document.createElement('img'); + imageElement.alt = ""; + imageElement.src = imageUrl; + if( !showSceneImage(imageElement) ) storyContainer.appendChild(imageElement); + showAfter(delay, imageElement); + delay += 200.0; + }); + } + // Check if paragraphText is empty if (paragraphText.trim().length == 0) { - continue; // Skip empty paragraphs + appendPendingImages(); + continue; // A tag-only line can still display images. } + // The story title and the author line follow the first paragraph below them. + if( paragraphAlign && !storyContainer.querySelector("p:not(.choice)") ) + setHeaderAlignment(paragraphAlign); + // Create paragraph element (initially hidden) var paragraphElement = document.createElement('p'); - paragraphElement.innerHTML = paragraphText; + paragraphElement.appendChild(storyTextNode(paragraphText)); + if( effectiveAlign ) paragraphElement.classList.add("align" + effectiveAlign); storyContainer.appendChild(paragraphElement); + appendPendingImages(); // Add any custom classes derived from ink tags - for(var i=0; i${choice.text}` - }else{ - choiceParagraphElement.innerHTML = `${choice.text}` + var choiceButton = document.createElement("button"); + choiceButton.type = "button"; + choiceButton.className = "choice-button"; + choiceButton.appendChild(document.createTextNode(plainStoryText(choice.text))); + choiceParagraphElement.appendChild(choiceButton); + } else { + var unclickableChoice = document.createElement("span"); + unclickableChoice.className = "unclickable"; + unclickableChoice.appendChild(document.createTextNode(plainStoryText(choice.text))); + choiceParagraphElement.appendChild(unclickableChoice); } - storyContainer.appendChild(choiceParagraphElement); + if( visibleChoiceTags.length ) { + var choiceTagsElement = document.createElement("span"); + choiceTagsElement.className = "tags"; + choiceTagsElement.textContent = " # " + visibleChoiceTags.join(" # "); + if(isClickable) choiceButton.appendChild(choiceTagsElement); + else choiceParagraphElement.appendChild(choiceTagsElement); + } + choicesContainer.appendChild(choiceParagraphElement); + choicesContainer.hidden = false; + storyFrame.classList.add("has-choices"); // Fade choice in after a short delay showAfter(delay, choiceParagraphElement); @@ -207,25 +725,18 @@ // Click on choice if(isClickable){ - var choiceAnchorEl = choiceParagraphElement.querySelectorAll("a")[0]; - choiceAnchorEl.addEventListener("click", function(event) { - - // Don't follow link - event.preventDefault(); + choiceButton.addEventListener("click", function() { - // Extend height to fit - // We do this manually so that removing elements and creating new ones doesn't - // cause the height (and therefore scroll) to jump backwards temporarily. - storyContainer.style.height = contentBottomEdgeY()+"px"; - - // Remove all existing choices - removeAll(".choice"); + // The chosen branch replaces the passage on screen, as the Preview does. + clearScene(); // Tell the story where to go next + selectedChoiceText = choice.text; + choiceTags.forEach(processAudioTag); story.ChooseChoiceIndex(choice.index); // This is where the save button will save from - savePoint = story.state.toJson(); + markSavePoint(); // Aaand loop continueStory(); @@ -233,25 +744,49 @@ } }); - // Unset storyContainer's height, allowing it to resize itself - storyContainer.style.height = ""; - - if( !firstTime ) - scrollDown(previousBottomEdge); + // No more content and no choices: the Ink story has ended. + // This does not interfere with tunnels or live threads: they keep the + // engine continuing until it reaches a genuine terminal state. + if( !story.canContinue && story.currentChoices.length === 0 ) { + if( endingEnabled ) { + const marker = EndingMarker.create(document, endingText); + storyContainer.appendChild(marker); + showAfter(delay, marker); + } + // Reader-owned replay is not an Ink choice; it must never suppress the ending. + if (playAgainEnabled) { + const replay = EndingMarker.createReplayChoice(document, playAgainText); + replay.querySelector("button").addEventListener("click", returnToTitleScreen); + choicesContainer.appendChild(replay); + choicesContainer.hidden = false; + storyFrame.classList.add("has-choices"); + showAfter(delay, replay); + } + } + // A passage divided into pages starts at its first page: nothing scrolls. + if( pager ) pager.refresh(); + if( !storyFrame.classList.contains("is-paged") ) scrollToNewContent(); + updateMoreBelow(); } function restart() { + sceneAlign = null; + selectedChoiceText = null; + savePointChoiceText = null; + audioEngine.stopSoundEffects(); + clearScene(); story.ResetState(); setVisible(".header", true); // set save point to here - savePoint = story.state.toJson(); + markSavePoint(); - continueStory(true); + continueStory(); - outerScrollContainer.scrollTo(0, 0); + storyContainer.scrollTop = 0; + resetScrollFill(); } // ----------------------------------- @@ -274,41 +809,64 @@ } } - // Scrolls the page down, but no further than the bottom edge of what you could - // see previously, so it doesn't go too far. - function scrollDown(previousBottomEdge) { - // If the user doesn't want animations, let them scroll manually - if ( !isAnimationEnabled() ) { - return; - } + // Clear the story content and scene-specific background while preserving the page header. + // The incoming line or selected branch is rendered after this call. + // The header (story title + author) takes the alignment of the first paragraph + // below it, or the story alignment when that paragraph has no "# align:" tag. + function setHeaderAlignment(alignment) { + var header = storyFrame.querySelector(".header"); + if( !header ) return; + header.classList.remove("alignLeft", "alignCenter", "alignRight"); + if( alignment ) header.classList.add("align" + alignment); + } - // Line up top of screen with the bottom of where the previous content ended - var target = previousBottomEdge; + // The first picture of a scene becomes the frame's visual panel; any further + // pictures stay in the text, as before. + function showSceneImage(imageElement) { + if( !storyVisual || !storyFrame ) return false; + if( storyFrame.classList.contains("has-visual") ) return false; + storyVisual.textContent = ""; + storyVisual.appendChild(imageElement); + storyVisual.hidden = false; + storyFrame.classList.add("has-visual"); + return true; + } - // Can't go further than the very bottom of the page - var limit = outerScrollContainer.scrollHeight - outerScrollContainer.clientHeight; - if( target > limit ) target = limit; + function clearStoryImage() { + if( !storyVisual || !storyFrame ) return; + storyVisual.textContent = ""; + storyVisual.hidden = true; + storyFrame.classList.remove("has-visual"); + } - var start = outerScrollContainer.scrollTop; + function clearStoryBackground() { + if( !storyFrame ) return; + storyFrame.style.backgroundImage = ""; + storyFrame.classList.remove("has-background"); + } - var dist = target - start; - var duration = 300 + 300*dist/100; - var startTime = null; - function step(time) { - if( startTime == null ) startTime = time; - var t = (time-startTime) / duration; - var lerp = 3*t*t - 2*t*t*t; // ease in/out - outerScrollContainer.scrollTo(0, (1.0-lerp)*start + lerp*target); - if( t < 1 ) requestAnimationFrame(step); + function clearChoices() { + if( choicesContainer ) { + choicesContainer.textContent = ""; + choicesContainer.hidden = true; + choicesContainer.scrollTop = 0; } - requestAnimationFrame(step); + storyFrame.classList.remove("has-choices"); } - // The Y coordinate of the bottom end of all the story content, used - // for growing the container, and deciding how far to scroll. - function contentBottomEdgeY() { - var bottomElement = storyContainer.lastElementChild; - return bottomElement ? bottomElement.offsetTop + bottomElement.offsetHeight : 0; + function clearScene() { + sceneAlign = null; + setHeaderAlignment(null); + storyContainer.scrollTop = 0; + resetScrollFill(); + removeAll("p"); + removeAll(".scene-title"); + removeAll(".story-ending"); + removeAll("img"); + removeAll("hr"); + clearChoices(); + clearStoryImage(); + clearStoryBackground(); } // Remove all elements that match the given selector. Used for removing choices after @@ -322,10 +880,53 @@ } } + // Bring the new turn to the top of the passage. When the turn is too short to + // reach that position, a filler below adds the scroll room. + function scrollToNewContent() { + function startTop() { + var dividers = storyContainer.querySelectorAll("hr"); + var newContent = dividers.length ? dividers[dividers.length - 1].nextElementSibling : null; + return newContent ? newContent.offsetTop : 0; + } + // The story's own height. scrollHeight is clamped to the viewport when the + // text is short, and the filler must not count as story. + function contentHeight() { + var last = storyContainer.lastElementChild; + if( !last ) return 0; + var paddingBottom = parseFloat(window.getComputedStyle(storyContainer).paddingBottom) || 0; + return last.offsetTop + last.offsetHeight + paddingBottom; + } + var paddingTop = parseFloat(window.getComputedStyle(storyContainer).paddingTop) || 0; + + function fillFor(target) { + return target > 0 ? Math.max(0, target + storyContainer.clientHeight - contentHeight()) : 0; + } + + var target = Math.max(0, startTop() - paddingTop); + storyContainer.style.setProperty("--story-scroll-fill", fillFor(target) + "px"); + // Once the filler makes the passage overflow, a short turn is no longer + // centred, so its position is measured again before scrolling. + target = Math.max(0, startTop() - paddingTop); + storyContainer.style.setProperty("--story-scroll-fill", fillFor(target) + "px"); + storyContainer.style.setProperty("--story-top-cover", paddingTop + "px"); + storyContainer.scrollTop = target; + } + + function resetScrollFill() { + storyContainer.style.setProperty("--story-scroll-fill", "0px"); + storyContainer.style.setProperty("--story-top-cover", "0px"); + } + + // The filler below the last turn is scroll room, not story: it never counts as + // "more below". + function scrollFill() { + return parseFloat(window.getComputedStyle(storyContainer).getPropertyValue("--story-scroll-fill")) || 0; + } + // Used for hiding and showing the header when you CLEAR or RESTART the story respectively. function setVisible(selector, visible) { - var allElements = storyContainer.querySelectorAll(selector); + var allElements = storyFrame.querySelectorAll(selector); for(var i=0; i button").forEach(function(button) { + button.addEventListener("click", closePopovers); + }); + } let rewindEl = document.getElementById("rewind"); if (rewindEl) rewindEl.addEventListener("click", function(event) { - removeAll("p"); - removeAll("img"); - setVisible(".header", false); - restart(); + closePopovers(); + returnToTitleScreen(); }); let saveEl = document.getElementById("save"); if (saveEl) saveEl.addEventListener("click", function(event) { + closePopovers(); + if( !storyStarted ) return; try { - window.localStorage.setItem('save-state', savePoint); - document.getElementById("reload").removeAttribute("disabled"); - window.localStorage.setItem('theme', document.body.classList.contains("dark") ? "dark" : ""); + window.localStorage.setItem(saveKey, savePoint); + window.localStorage.setItem(saveKey + ":music", savePointMusic); + ChoiceText.saveSelectedChoiceText(window.localStorage, saveKey, savePointChoiceText); + hasSave = true; + var titleLoad = document.getElementById("title-load"); + if( titleLoad ) titleLoad.disabled = false; } catch (e) { console.warn("Couldn't save state"); } }); - let reloadEl = document.getElementById("reload"); - if (!hasSave) { - reloadEl.setAttribute("disabled", "disabled"); - } - reloadEl.addEventListener("click", function(event) { - if (reloadEl.getAttribute("disabled")) - return; - removeAll("p"); - removeAll("img"); - try { - let savedState = window.localStorage.getItem('save-state'); - if (savedState) story.state.LoadJson(savedState); - } catch (e) { - console.debug("Couldn't load save state"); - } - continueStory(true); - }); - - let themeSwitchEl = document.getElementById("theme-switch"); - if (themeSwitchEl) themeSwitchEl.addEventListener("click", function(event) { - document.body.classList.add("switched"); - document.body.classList.toggle("dark"); - }); } })(storyContent); diff --git a/app/export-for-web-template/style.css b/app/export-for-web-template/style.css index 112959c5..4111517c 100644 --- a/app/export-for-web-template/style.css +++ b/app/export-for-web-template/style.css @@ -1,10 +1,29 @@ -@import url('https://fonts.googleapis.com/css?family=Open+Sans:300,700|Quattrocento:700'); +/* + Exported story: the game frame, not a web page. + The story lives inside a single 9:16 frame. There is no toolbar: one small menu + button float over the frame, and the passage scrolls inside it. +*/ + +:root { + --frame-gap: clamp(10px, 2.4vmin, 36px); + --frame-radius: clamp(10px, 2vmin, 20px); + --frame-pad: clamp(16px, 2.6vmin, 26px); +} +html, body { - font-family: 'Open Sans', sans-serif; - font-weight: lighter; - background: white; + height: 100%; +} + +body { + margin: 0; overflow: hidden; + /* The game floats on a quiet surface; the story colors stay in the frame. */ + /* Outside surface follows the active story look (including custom colors). */ + background: var(--reader-outer-bg, #eeeeec); + color: var(--text, #000000); + font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", "Helvetica Neue", Arial, sans-serif; + font-weight: 400; } @media screen and (prefers-reduced-motion: no-preference) { @@ -16,7 +35,7 @@ body { h1, h2 { text-align: center; - font-family: "Quattrocento", Georgia, 'Times New Roman', Times, serif; + font-family: Georgia, "Times New Roman", "Noto Serif", serif; margin: 0; padding: 0; } @@ -34,109 +53,526 @@ h2 { color: #BBB; } -.header { - padding-top: 3em; - padding-bottom: 3em; -} - /* - Built-in class: - # author: Name + Enables + +
+ + + + + + diff --git a/app/renderer/editorView.js b/app/renderer/editorView.js index 1ef11fe2..4f8a5726 100644 --- a/app/renderer/editorView.js +++ b/app/renderer/editorView.js @@ -2,6 +2,7 @@ const editor = ace.edit("editor"); const Range = ace.require("ace/range").Range; const TokenIterator = ace.require("ace/token_iterator").TokenIterator; const language_tools = ace.require("ace/ext/language_tools"); +const TagFormat = require("./tagFormat.js").TagFormat; const inkCompleter = require("./inkCompleter.js").inkCompleter; @@ -24,6 +25,7 @@ editor.setShowPrintMargin(false); editor.setOptions({ enableBasicAutocompletion: true, // defaults only, will be overriden by setAutoCompleteDisabled enableLiveAutocompletion: true, + behavioursEnabled: true, }); editor.on("change", () => { events.change(); @@ -36,6 +38,11 @@ editor.on("changeSelection", ()=>{ editor.completers = editor.completers.filter( (completer) => completer !== language_tools.textCompleter); editor.completers.push(inkCompleter); +require("./tagPicker.js").TagPicker.install(editor, inkCompleter); +require("./fileReference.js").FileReference.install(editor, inkCompleter); +require("./yamlTitleGuard.js").YamlTitleGuard.install(editor); +const richText = require("./richTextFormatting.js").RichTextFormatting.install(editor, Range); +require("./richTextToolbar.js").RichTextToolbar.install(editor, richText); // Unbind windows CTRL-P: "Jump to matching bracket" since it collides with // our "go to anything" command. @@ -152,7 +159,24 @@ function clearErrors() { editorMarkers = []; } +// Tags have a single accepted shape (#tag: value, always flush left). Anything typed, +// pasted or dropped in a different shape is fixed before the operation ends, in the same +// undo step, so the wrong form never stays in the editor. +var currentInkFile = null; +editor.on("beforeEndOperation", function() { + if( !currentInkFile || !currentInkFile.isInkFile ) return; + if( !editor.curOp || !editor.curOp.docChanged ) return; + var session = editor.session; + if( session.$fromUndo ) return; + TagFormat.fixDocument(session.doc); +}); + exports.EditorView = { + canFormatCode: () => !!(currentInkFile && currentInkFile.isInkFile && !editor.getReadOnly()), + formatCode: () => { + if (!currentInkFile || !currentInkFile.isInkFile || editor.getReadOnly()) return false; + return require("./codeFormat.js").formatEditor(editor, Range); + }, clearErrors: clearErrors, setEvents: (e) => { events = e; }, getValue: () => { return editor.getValue(); }, @@ -166,6 +190,7 @@ exports.EditorView = { }, showInkFile: (inkFile) => { clearErrors(); + currentInkFile = inkFile; editor.setSession(inkFile.getAceSession()); editor.focus(); }, diff --git a/app/renderer/endingMarker.js b/app/renderer/endingMarker.js new file mode 100644 index 00000000..a1e92224 --- /dev/null +++ b/app/renderer/endingMarker.js @@ -0,0 +1,56 @@ +// The same ending ornament in the IDE Preview and the exported player. +// Build nodes via textContent, never HTML, because game.yaml is author input. +(function(root, factory) { + if (typeof module === "object" && module.exports) module.exports = factory(); + else root.InkyEndingMarker = factory(); +})(typeof globalThis !== "undefined" ? globalThis : this, function() { + "use strict"; + const DEFAULT_TEXT = "The End"; + function create(doc, label) { + const container = doc.createElement("div"); + container.className = "story-ending"; + container.setAttribute("role", "status"); + // Real SVG strokes keep both rules visible. The previous CSS flex + // pseudo-elements could collapse, leaving the diamond alone. + const SVG_NS = "http://www.w3.org/2000/svg"; + const ornament = doc.createElementNS(SVG_NS, "svg"); + ornament.setAttribute("class", "story-ending-ornament"); + ornament.setAttribute("viewBox", "0 0 240 24"); + ornament.setAttribute("aria-hidden", "true"); + ornament.setAttribute("focusable", "false"); + for (const d of ["M0 12 H104", "M136 12 H240"]) { + const line = doc.createElementNS(SVG_NS, "path"); + line.setAttribute("class", "story-ending-line"); + line.setAttribute("d", d); + ornament.appendChild(line); + } + const diamond = doc.createElementNS(SVG_NS, "path"); + diamond.setAttribute("class", "story-ending-diamond"); + diamond.setAttribute("d", "M120 3 L129 12 L120 21 L111 12 Z"); + ornament.appendChild(diamond); + const caption = doc.createElement("span"); + caption.className = "story-ending-label"; + caption.textContent = typeof label === "string" && label.trim() ? label : DEFAULT_TEXT; + container.appendChild(ornament); + container.appendChild(caption); + return container; + } + function update(element, visible, label) { + if (!element) return; + element.hidden = !visible; + const caption = element.querySelector(".story-ending-label"); + if (caption) caption.textContent = typeof label === "string" && label.trim() ? label : DEFAULT_TEXT; + } + const REPLAY_LABEL = "Play Again"; + function createReplayChoice(doc, label) { + const choice = doc.createElement("p"); + choice.className = "choice story-replay-choice"; + const button = doc.createElement("button"); + button.type = "button"; + button.className = "choice-button"; + button.textContent = typeof label === "string" && label.trim() ? label : REPLAY_LABEL; + choice.appendChild(button); + return choice; + } + return { DEFAULT_TEXT, REPLAY_LABEL, create, update, createReplayChoice }; +}); diff --git a/app/renderer/explorerTree.css b/app/renderer/explorerTree.css new file mode 100644 index 00000000..a0914e5c --- /dev/null +++ b/app/renderer/explorerTree.css @@ -0,0 +1,103 @@ +/* Explorer tree: jsTree (jstree/themes/default) dressed like the rest of Inky. */ + +/* The tree fills the panel, so the empty space under the last row is a drop target for the project folder. */ +#file-nav-wrapper #explorer-tree { padding: 2px 4px 14px 2px; min-width: 0; min-height: calc(100vh - 110px); box-sizing: border-box; } +#file-nav-wrapper .jstree-default { color: var(--sidebar-text, #333); } +#file-nav-wrapper .jstree-default .jstree-anchor { + display: inline-flex; + align-items: center; + gap: 6px; + box-sizing: border-box; + max-width: calc(100% - 26px); + height: 24px; + padding: 0 4px; + line-height: 24px; + vertical-align: top; + color: inherit; + text-decoration: none; + user-select: none; + overflow: hidden; +} +/* The row's own highlight is jsTree's whole-row element; the anchor stays transparent. */ +#file-nav-wrapper .jstree-default .jstree-anchor.jstree-hovered, +#file-nav-wrapper .jstree-default .jstree-anchor.jstree-clicked, +#file-nav-wrapper .jstree-default .jstree-anchor.jstree-hovered.jstree-clicked { + background: transparent; box-shadow: none; color: inherit; border-radius: 0; +} +#file-nav-wrapper .jstree-default .jstree-wholerow-hovered { background: rgba(127,127,127,.12); } +#file-nav-wrapper .jstree-default .jstree-wholerow-clicked { background: rgba(0,102,204,.17); box-shadow: inset 0 0 0 1px rgba(0,102,204,.45); } +.window.dark #file-nav-wrapper .jstree-default .jstree-wholerow-clicked { background: rgba(60,140,220,.28); box-shadow: inset 0 0 0 1px rgba(100,170,240,.5); } +/* Adjacent selected rows keep a thin gap between their bands (the row keeps its height; only the painted band is shorter). */ +#file-nav-wrapper .jstree-default .jstree-wholerow-clicked { box-sizing: border-box; border-top: 1px solid transparent; border-bottom: 1px solid transparent; background-clip: padding-box; } +#file-nav-wrapper .jstree-default .jstree-anchor:focus, +#file-nav-wrapper .jstree-default .jstree-anchor:focus-visible { outline: none; } + +#file-nav-wrapper .ex-row .icon { flex: 0 0 14px; width: 14px; height: 18px; display: inline-flex; align-items: center; justify-content: center; } +#file-nav-wrapper .ex-row .filename { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +#file-nav-wrapper .ex-file .ink-filename { display: flex; align-items: center; } +#file-nav-wrapper .ex-file .ink-filename .filename-stem { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +#file-nav-wrapper .ex-file .ink-filename .filename-extension { flex: 0 0 auto; white-space: nowrap; } +#file-nav-wrapper .ex-yaml > .icon { opacity: .8; } +#file-nav-wrapper .ex-root > .filename { font-weight: 600; } + +/* Folder icons: closed and open */ +#file-nav-wrapper .ex-folder > .folder-icon { flex-basis: 16px; width: 16px; opacity: .9; } +#file-nav-wrapper .folder-icon svg { display: block; width: 16px; height: 16px; } +#file-nav-wrapper .folder-icon-open { display: none; } +#file-nav-wrapper .folder-icon-closed { fill: currentColor; fill-opacity: .12; } +#file-nav-wrapper .folder-icon-open { fill: currentColor; fill-opacity: .08; } +#file-nav-wrapper .folder-icon-open-face { fill-opacity: .18; } +#file-nav-wrapper .jstree-open > .jstree-anchor .folder-icon-closed { display: none; } +#file-nav-wrapper .jstree-open > .jstree-anchor .folder-icon-open { display: initial; } + +/* Row states */ +#file-nav-wrapper .ex-active .filename, +#file-nav-wrapper .ex-unsaved .filename { font-weight: bold; } +#file-nav-wrapper .ex-loading .filename { opacity: .5; } +#file-nav-wrapper .ex-audio .icon-audio { opacity: .85; } +#file-nav-wrapper .ex-audio .audio-wave { display: none; } +#file-nav-wrapper .ex-audio.ex-playing .audio-wave { display: inline; } +#file-nav-wrapper .ex-audio.ex-playing { color: #0066cc; } +.window.dark #file-nav-wrapper .ex-audio.ex-playing { color: #6ab0f3; } + +/* A folder a drop would go into (dragging inside the Explorer, or files from the file manager) */ +#file-nav-wrapper .jstree-default .jstree-anchor.explorer-drop-target { outline: 2px solid #0066cc; outline-offset: -2px; background: rgba(0,102,204,.14); } +.window.dark #file-nav-wrapper .jstree-default .jstree-anchor.explorer-drop-target { outline-color: #6ab0f3; background: rgba(106,176,243,.16); } + +/* The name being typed (new item or rename) */ +#file-nav-wrapper .jstree-rename-input { + min-width: 90px; + padding: 0 4px !important; + border: 1px solid #1676d2 !important; + border-radius: 3px; + background: #fff; + color: #222; + font: inherit; + box-shadow: 0 0 0 1px rgba(22,118,210,.2); +} +.window.dark #file-nav-wrapper .jstree-rename-input { background: #1f1f1f; color: #eee; border-color: #4b7296 !important; } + +/* "Add INCLUDE to main story", shown while a new scene is being named */ +#file-nav-wrapper .explorer-create-options { display: flex; align-items: center; gap: 5px; margin: 2px 10px 4px; color: #666; font-size: 11px; user-select: none; } +#file-nav-wrapper .explorer-create-options[hidden] { display: none; } +#file-nav-wrapper .explorer-create-options input { margin: 0; } +.window.dark #file-nav-wrapper .explorer-create-options { color: #bbb; } + +/* The right-click menu is jsTree's (it is drawn on the page, outside the Explorer). + The theme reserves a column for icons and draws a divider after it; this menu has no icons. */ +.vakata-context.jstree-default-contextmenu { font: 13px/1.4 -apple-system, "Segoe UI", sans-serif; z-index: 2000; min-width: 190px; padding: 4px; border-radius: 6px; border-color: #c8c8c8; box-shadow: 0 4px 14px rgba(0,0,0,.22); } +.vakata-context.jstree-default-contextmenu li > a { display: flex; align-items: center; padding: 0 12px; line-height: 26px; border-radius: 4px; text-shadow: none; } +.vakata-context.jstree-default-contextmenu li > a > i, +.vakata-context.jstree-default-contextmenu li > a .vakata-contextmenu-sep { display: none; } +.vakata-context.jstree-default-contextmenu li > a:hover, +.vakata-context.jstree-default-contextmenu .vakata-context-hover > a { background: #e4eefb; box-shadow: none; } +.vakata-context.jstree-default-contextmenu .vakata-contextmenu-shortcut { display: inline-block; margin-left: auto; padding-left: 28px; font-size: 11px; opacity: .55; color: inherit; } +.vakata-context.jstree-default-contextmenu li.vakata-contextmenu-disabled > a { opacity: .45; color: inherit; } +.vakata-context.jstree-default-contextmenu .vakata-context-separator > a, +.vakata-context.jstree-default-contextmenu .vakata-context-separator > a:hover { display: block; height: 1px; min-height: 1px; max-height: 1px; margin: 4px 6px; padding: 0; line-height: 0; border: 0; border-top: 1px solid #dcdcdc; background: transparent; box-shadow: none; } +.window.dark .vakata-context.jstree-default-contextmenu { background: #2b2b2b; border-color: #555; color: #ddd; box-shadow: 0 4px 14px rgba(0,0,0,.5); } +.window.dark .vakata-context.jstree-default-contextmenu li > a { color: #ddd; } +.window.dark .vakata-context.jstree-default-contextmenu li > a:hover, +.window.dark .vakata-context.jstree-default-contextmenu .vakata-context-hover > a { background: #3b4a5c; color: #fff; } +.window.dark .vakata-context.jstree-default-contextmenu .vakata-context-separator > a, +.window.dark .vakata-context.jstree-default-contextmenu .vakata-context-separator > a:hover { border-top-color: #555; background: transparent; } diff --git a/app/renderer/explorerTree.js b/app/renderer/explorerTree.js new file mode 100644 index 00000000..2c8e4aba --- /dev/null +++ b/app/renderer/explorerTree.js @@ -0,0 +1,216 @@ +"use strict"; + +// The Explorer's data. jsTree draws the tree, handles selection, keyboard, inline editing and +// dragging; this module only turns the project (documents, folders, sound and picture files) +// into the node list jsTree loads, and answers the few questions the Explorer asks about nodes. +// +// Node ids are stable across redraws: "root" for the project folder, "d:" for a folder and +// "f:" for a file, so open folders and the selection survive a refresh. + +const fs = require("fs"); +const path = require("path"); + +const IGNORED_FOLDERS = [".git", "node_modules", ".DS_Store"]; + +const ROOT_ID = "root"; +const folderId = folderPath => folderPath ? "d:" + folderPath : ROOT_ID; +const fileId = filePath => "f:" + filePath; + +function normalizePath(value) { + return String(value || "").replace(/\\/g, "/").replace(/^\.\//, ""); +} + +// Every sub-folder of the project, as relative paths ("a", "a/b"), empty ones included. +function scanFolders(projectDir) { + const collected = []; + const scan = (absoluteDir, relativeDir) => { + let entries; + try { entries = fs.readdirSync(absoluteDir, { withFileTypes: true }); } + catch(error) { return; } + entries.forEach(entry => { + if( !entry.isDirectory() || entry.isSymbolicLink() ) return; + if( IGNORED_FOLDERS.indexOf(entry.name) !== -1 ) return; + const relative = relativeDir ? relativeDir + "/" + entry.name : entry.name; + collected.push(relative); + scan(path.join(absoluteDir, entry.name), relative); + }); + }; + if( projectDir && fs.existsSync(projectDir) ) scan(projectDir, ""); + return collected; +} + +const FOLDER_ICON = + ''; +const AUDIO_ICON = + ''; +const IMAGE_ICON = + ''; + +// What a document row says: the name, with the ".ink" kept visible even when the stem is cut short. +function documentText(file, escapeHtml) { + const raw = String(file.filename() || ""); + const visible = file.isInkFile && !/\.ink$/i.test(raw) ? raw + ".ink" : raw; + const icon = ''; + if( file.isInkFile && /\.ink$/i.test(visible) ) { + return icon + '' + escapeHtml(visible.slice(0, -4)) + + '' + escapeHtml(visible.slice(-4)) + ''; + } + return icon + '' + escapeHtml(visible) + ''; +} + +// options: { mainInk, allFiles, projectDir, folders, audioFiles, imageFiles, openState, +// selectedIds (Set), playingAudioPath, escapeHtml } +function buildExplorerNodes(options) { + const escapeHtml = options.escapeHtml || (value => String(value)); + const openState = options.openState || {}; + const selectedIds = options.selectedIds || new Set(); + const isOpen = folderPath => openState[folderPath] !== false; + + // The main story is a document like any other; de-duplicate by relative path. + const documents = new Map(); + [options.mainInk].concat(options.allFiles || []).forEach(file => { + if( !file || (file !== options.mainInk && !file.isInkFile && !file.isYamlFile) ) return; + const relative = normalizePath(file.relativePath()); + if( relative && !documents.has(relative) ) documents.set(relative, file); + }); + + // A folder tree: first the folders on disk (empty ones too), then every file's own folder. + const rootFolder = { name: "", path: "", children: Object.create(null), files: [] }; + const ensureFolder = relativeFolder => { + let current = rootFolder; + let currentPath = ""; + normalizePath(relativeFolder).split("/").filter(Boolean).forEach(segment => { + currentPath = currentPath ? currentPath + "/" + segment : segment; + if( !current.children[segment] ) current.children[segment] = { name: segment, path: currentPath, children: Object.create(null), files: [] }; + current = current.children[segment]; + }); + return current; + }; + (options.folders || []).forEach(folderPath => ensureFolder(folderPath)); + + const entries = []; + documents.forEach((file, relative) => { + entries.push({ kind: file.isYamlFile ? "yaml" : "ink", relative: relative, file: file }); + }); + (options.audioFiles || []).forEach(raw => entries.push({ kind: "audio", relative: normalizePath(raw) })); + (options.imageFiles || []).forEach(raw => entries.push({ kind: "image", relative: normalizePath(raw) })); + entries.forEach(entry => { + const folder = path.posix.dirname(entry.relative); + (folder === "." ? rootFolder : ensureFolder(folder)).files.push(entry); + }); + + const nodes = []; + const classesOf = (entry) => { + const file = entry.file; + const classes = ["ex-row", "ex-file", "ex-" + entry.kind]; + if( file && file.hasUnsavedChanges ) classes.push("ex-unsaved"); + if( file && file.isLoading ) classes.push("ex-loading"); + if( file && file.isActive ) classes.push("ex-active"); + if( entry.kind === "audio" && options.playingAudioPath === entry.relative ) classes.push("ex-playing"); + return classes.join(" "); + }; + const addFile = (entry, parentId) => { + const name = path.posix.basename(entry.relative); + const id = fileId(entry.relative); + let text; + if( entry.kind === "audio" ) text = AUDIO_ICON + '' + escapeHtml(name) + ''; + else if( entry.kind === "image" ) text = IMAGE_ICON + '' + escapeHtml(name) + ''; + else text = documentText(entry.file, escapeHtml); + const title = entry.kind === "audio" ? entry.relative + " — click to listen" + : entry.kind === "image" ? entry.relative + " — click to preview" : entry.relative; + const attributes = { "class": classesOf(entry), "title": title, "data-relative-path": entry.relative }; + if( entry.file && entry.file.id !== undefined && entry.file.id !== null ) attributes["data-file-id"] = String(entry.file.id); + nodes.push({ + id: id, + parent: parentId, + text: text, + state: { opened: false, selected: selectedIds.has(id) }, + a_attr: attributes, + data: { + type: "file", + kind: entry.kind, + path: entry.relative, + name: entry.file ? String(entry.file.filename() || name) : name, + fileId: entry.file && entry.file.id !== undefined && entry.file.id !== null ? entry.file.id : null + } + }); + }; + const byName = (a, b) => a.localeCompare(b); + const addFolder = (folder, parentId) => { + const id = folderId(folder.path); + nodes.push({ + id: id, + parent: parentId, + text: FOLDER_ICON + '' + escapeHtml(folder.name) + '', + state: { opened: isOpen(folder.path), selected: selectedIds.has(id) }, + a_attr: { "class": "ex-row ex-folder", "data-folder-path": folder.path }, + data: { type: "folder", kind: "folder", path: folder.path, name: folder.name } + }); + addChildren(folder, id); + }; + // Folders first, then files, each in alphabetical order: the default VS Code ordering. + const addChildren = (folder, parentId) => { + Object.keys(folder.children).sort(byName).forEach(key => addFolder(folder.children[key], parentId)); + folder.files.sort((a, b) => byName(path.posix.basename(a.relative), path.posix.basename(b.relative))) + .forEach(entry => addFile(entry, parentId)); + }; + + const projectName = options.projectDir ? path.basename(path.resolve(options.projectDir)) : "Project"; + nodes.push({ + id: ROOT_ID, + parent: "#", + text: FOLDER_ICON + '' + escapeHtml(projectName) + '', + state: { opened: isOpen(""), selected: selectedIds.has(ROOT_ID) }, + a_attr: { "class": "ex-row ex-folder ex-root", "title": options.projectDir || projectName, "data-folder-path": "" }, + data: { type: "folder", kind: "root", root: true, path: "", name: projectName } + }); + addChildren(rootFolder, ROOT_ID); + return nodes; +} + +// The project folder ("" for its root) a drop on this jsTree node goes to: a folder takes it, a +// file hands it to its own folder, and the tree's blank space ("#") is the project folder. +function dropFolderOf(node) { + if( !node || node.id === "#" ) return ""; + const data = node.data || {}; + if( data.root ) return ""; + if( data.type === "folder" ) return data.path || ""; + const parent = path.posix.dirname(String(data.path || ".")); + return parent === "." ? "" : parent; +} + +// A folder cannot go into itself or into one of its own sub-folders. +function canMoveInto(items, folder) { + return items.length > 0 && items.every(item => !(item.type === "folder" && (folder === item.path || folder.startsWith(item.path + "/")))); +} + +// What the rest of the app calls an item: { type, path, name, fileId, audio }, or null for the +// project folder and for the row being typed. +function nodeTarget(node) { + const data = node && node.data; + if( !data || data.root || data.pending ) return null; + return { type: data.type, path: data.path, name: data.name, fileId: data.fileId || null, audio: data.kind === "audio" }; +} + +function targetNodeId(target) { + return target.type === "folder" ? folderId(target.path) : fileId(target.path); +} + +module.exports = { + ROOT_ID, + folderId, + fileId, + normalizePath, + scanFolders, + buildExplorerNodes, + dropFolderOf, + canMoveInto, + nodeTarget, + targetNodeId +}; diff --git a/app/renderer/fileReference.js b/app/renderer/fileReference.js new file mode 100644 index 00000000..4d0f5ecf --- /dev/null +++ b/app/renderer/fileReference.js @@ -0,0 +1,135 @@ +"use strict"; + +// A file reference is the value of a tag that names a file: "#image: capa.jpg", +// "#music: dark.mp3", "#sound: porta.wav". The writer either has the reference or does not: +// +// - it is never edited piece by piece: nothing can be typed, pasted or deleted inside it, and +// nothing can be typed right before it or glued to its end; +// - Backspace anywhere in its value removes all of it (see tagPicker.js), opening the file list again; +// - a reference can only be created by picking a file the list offers; +// - removing a whole reference, or a whole line or tag that holds it, is always allowed, and so +// are undo and redo. +// +// Any value of these tags counts, "stop" included: it is picked from the tag list as one piece. + +const { TagFormat } = require("./tagFormat.js"); +const { TagPicker } = require("./tagPicker.js"); +const FILE_TAGS = TagPicker.FILE_VALUE_TAGS; + +// The file tags of the story code, up to `lastRow`: +// [{row, tag, start, end, rawEnd, kind}] where kind is "reference" (the tag has a value) +// or "empty" (the tag waits for its file). +function fileTags(lines, lastRow) { + const result = []; + TagFormat.tagValues(lines, lastRow).forEach(item => { + const tag = item.name.toLowerCase(); + if( FILE_TAGS.indexOf(tag) === -1 ) return; + const value = item.end === item.start ? "" : lines[item.row].slice(item.start, item.end); + const kind = !value ? "empty" : TagPicker.isAtomicValue(tag, value) ? "reference" : "incomplete"; + result.push({ row: item.row, tag: tag, start: item.start, end: item.end, rawEnd: item.rawEnd, value: value, kind: kind }); + }); + return result; +} + +// The reference that ends exactly at (row, column), or null. +function referenceEndingAt(lines, row, column) { + const found = fileTags(lines, row).filter(item => item.row === row && item.kind === "reference" && item.end === column); + return found.length ? found[0] : null; +} + +function isBefore(a, b) { + return a.row < b.row || (a.row === b.row && a.column < b.column); +} + +// May this change (an Ace delta, applied to `lines` as they are now) go through? +// isOffered(tag, name) tells whether the file list of that tag offers that name. +function allowsDelta(lines, delta, isOffered, allowPartialReferenceDelete) { + const first = delta.start.row; + const last = delta.action === "insert" ? delta.start.row : delta.end.row; + let hasHash = false; + for( let row = first; row <= last && row < lines.length; row++ ) { + if( lines[row].indexOf("#") !== -1 ) { hasHash = true; break; } + } + if( !hasHash ) return true; + + const tags = fileTags(lines, last); + + if( delta.action === "insert" ) { + const row = delta.start.row; + const column = delta.start.column; + const text = delta.lines; + const firstLine = text[0]; + const multiline = text.length > 1; + const onlyBlanks = text.every(line => /^\s*$/.test(line)); + for( let i = 0; i < tags.length; i++ ) { + const item = tags[i]; + if( item.row !== row ) continue; + if( item.kind === "reference" ) { + if( column > item.start && column < item.end ) return false; + if( column === item.start && !(onlyBlanks && !multiline) ) return false; + if( column === item.end && !(/^[\s#]/.test(firstLine) || (multiline && firstLine === "")) ) return false; + } else if( (item.kind === "empty" || item.kind === "incomplete") && column >= item.start && column <= item.rawEnd ) { + if( onlyBlanks ) continue; + if( !multiline ) continue; + return false; + } + } + return true; + } + + for( let i = 0; i < tags.length; i++ ) { + const item = tags[i]; + if( item.kind !== "reference" ) continue; + const from = { row: item.row, column: item.start }; + const to = { row: item.row, column: item.end }; + if( !isBefore(from, delta.end) || !isBefore(delta.start, to) ) continue; // does not touch it + if( allowPartialReferenceDelete && delta.action === "remove" ) continue; + const coversIt = !isBefore(from, delta.start) && !isBefore(delta.end, to); + if( !coversIt ) return false; + } + return true; +} + +// Guards every document the editor shows: a change that would edit a reference is dropped +// before it reaches the text. Undo and redo are never held back. +function install(editor, completer) { + let blocked = false; + + function guard(session) { + const doc = session && session.doc; + if( !doc || doc.$fileReferenceGuard ) return; + doc.$fileReferenceGuard = true; + const applyDelta = doc.applyDelta; + doc.applyDelta = function(delta, doNotValidate) { + const hasSelection = editor.selection && !editor.selection.isEmpty(); + if( !session.$fromUndo && !allowsDelta(this.getAllLines(), delta, null, hasSelection) ) { + blocked = true; + return; + } + return applyDelta.call(this, delta, doNotValidate); + }; + } + + guard(editor.session); + editor.on("changeSession", event => guard(event.session)); + + // The live autocomplete opens on a typed letter even when the change was dropped; it only + // stays when the cursor waits for a file. + editor.commands.on("afterExec", () => { + if( !blocked ) return; + blocked = false; + if( !editor.completer || !editor.completer.activated ) return; + const cursor = editor.getCursorPosition(); + const waiting = fileTags(editor.session.doc.getAllLines(), cursor.row).some(item => + item.row === cursor.row && item.kind === "empty" && cursor.column >= item.start && cursor.column <= item.rawEnd); + if( !waiting ) editor.completer.detach(); + }); +} + +exports.FileReference = { + FILE_TAGS: FILE_TAGS, + fileTags: fileTags, + referenceEndingAt: referenceEndingAt, + allowsDelta: allowsDelta, + install: install +}; diff --git a/app/renderer/focus.css b/app/renderer/focus.css index 054a3acf..be67a431 100644 --- a/app/renderer/focus.css +++ b/app/renderer/focus.css @@ -96,7 +96,7 @@ border-top-color: #444; } -.window.focus #player p.choice a:hover { +.window.focus #player p.choice .choice-button:hover { color: #999; } diff --git a/app/renderer/gameChrome.css b/app/renderer/gameChrome.css new file mode 100644 index 00000000..dcf2ed81 --- /dev/null +++ b/app/renderer/gameChrome.css @@ -0,0 +1,748 @@ +/* + Preview game chrome: the frame, the menu, the picture band, the + scrolling passage, the bottom fade and the scroll cue. + + This mirrors app/export-for-web-template/style.css rule by rule, so the writer sees the + same composition the player gets in the exported page. The exported stylesheet stays the + visual reference and is not modified; the selectors here are scoped to the Preview player + so the IDE's own chrome and themes keep working. +*/ + +#main #player { + /* The editor's chrome never determines the story's surrounding color. */ + background: var(--reader-outer-bg, #eeeeec); +} + +/* The viewer surface is the quiet page the frame floats on. The pane is the game's + viewport, so the spacing follows its container size, not the IDE window's. */ +#main #player .scrollContainer { + --frame-gap: clamp(10px, 2.4cqmin, 36px); + --frame-radius: clamp(10px, 2cqmin, 20px); + --frame-pad: clamp(16px, 2.6cqmin, 26px); + display: grid; + grid-template: minmax(0, 1fr) / minmax(0, 1fr); + place-items: center; + box-sizing: border-box; + padding: 0; + overflow: hidden; + container-type: size; + background: transparent; +} + +#main #player .scrollContainer::-webkit-scrollbar, +#main #player .scrollContainer::-webkit-scrollbar-track, +#main #player .scrollContainer::-webkit-scrollbar-corner { + display: none; +} + +/* The frame of the game, always 9:16. */ +#main #player .innerText.container { + position: relative; + display: flex; + flex-direction: column; + box-sizing: border-box; + width: min(calc(100cqw - 2 * var(--frame-gap)), var(--game-width, 42rem)); + min-height: 0; + max-width: none; + max-height: calc(100cqh - 2 * var(--frame-gap)); + margin: 0; + padding: 0; + overflow: hidden; + border: 1px solid var(--reader-frame-border, color-mix(in srgb, var(--game-text, #000000) 20%, transparent)); + border-radius: var(--frame-radius); + background: var(--game-bg, #ffffff); + color: var(--game-text, #000000); + /* Three restrained layers: depth, contact and a theme-tinted ambient rim. */ + box-shadow: + 0 26px 58px -13px var(--reader-shadow-deep, rgba(0, 0, 0, 0.24)), + 0 7px 23px -5px var(--reader-shadow-soft, rgba(0, 0, 0, 0.14)), + 0 0 34px 2px var(--reader-shadow-halo, rgba(0, 0, 0, 0.07)); + font-family: var(--game-font, system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", "Helvetica Neue", Arial, sans-serif); + font-size: inherit; + text-align: var(--game-story-align, left); +} + +/* #bg paints the full 9:16 frame behind its picture, passage and choices. */ +#main #player .innerText.container.has-background { + background-position: center; + background-size: cover; + background-repeat: no-repeat; +} + +/* Let the frame background show through the scene picture's transparent pixels and choices. + The choice surface keeps a translucent theme tint so its text remains readable. */ +#main #player .innerText.container.has-background .story-visual { + background: transparent; +} + +#main #player .innerText.container.has-background .story-choices { + background: transparent; +} + +#main #player .hiddenBuffer .innerText.container { + width: min(calc(100cqw - 2 * var(--frame-gap)), var(--game-width, 42rem)); + max-height: calc(100cqh - 2 * var(--frame-gap)); +} + +/* ---- Small overlay: the story's single menu ---------------------------------- */ + +#main #player .story-hud { + position: absolute; + z-index: 2; + inset: 0 0 auto; + display: flex; + align-items: flex-start; + justify-content: flex-end; + gap: 12px; + box-sizing: border-box; + padding: clamp(12px, 2.2cqmin, 22px) var(--frame-pad); + color: var(--game-text, #000000); + user-select: none; + pointer-events: none; +} + +#main #player .story-actions { + display: flex; + align-items: center; + flex: 0 0 auto; + pointer-events: auto; +} + +#main #player .innerText.has-visual .toolbar-icon-button { + color: #ffffff; + filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5)); +} + +#main #player .toolbar-icon-button { + display: grid; + place-items: center; + width: clamp(32px, 4.4cqmin, 42px); + height: clamp(32px, 4.4cqmin, 42px); + padding: 0; + border: 0; + border-radius: 50%; + background: transparent; + color: inherit; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; +} + +#main #player .toolbar-icon-button:hover, +#main #player .toolbar-icon-button[aria-expanded="true"] { + background: color-mix(in srgb, currentColor 15%, transparent); +} + +#main #player .toolbar-icon-button:focus-visible, +#main #player .toolbar-popover button:focus-visible, +#main #player .audio-control button:focus-visible { + outline: 2px solid var(--game-choice, #0066cc); + outline-offset: 2px; +} + +#main #player .toolbar-icon-button svg, +#main #player .toolbar-popover button svg { + box-sizing: border-box; + width: 20px; + height: 20px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +#main #player .toolbar-icon-button:disabled { + opacity: 0.42; + cursor: default; +} + +#main #player .toolbar-popover-anchor { + position: relative; +} + +#main #player .toolbar-popover { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 10; + box-sizing: border-box; + width: max-content; + min-width: 218px; + padding: 6px; + border: 1px solid color-mix(in srgb, var(--game-text, #000000) 14%, transparent); + border-radius: 12px; + background: var(--game-bg, #ffffff); + color: var(--game-text, #000000); + box-shadow: 0 18px 44px rgba(0, 0, 0, 0.22); + pointer-events: auto; +} + +#main #player #story-menu > button[hidden] { + display: none; +} + +#main #player .toolbar-popover[hidden] { + display: none; +} + +#main #player #story-menu > button { + display: flex; + align-items: center; + width: 100%; + min-height: 40px; + gap: 10px; + padding: 8px 10px; + border: 0; + border-radius: 8px; + background: transparent; + color: inherit; + font: inherit; + font-size: 0.92em; + line-height: normal; + text-align: left; + cursor: pointer; +} + +#main #player #story-menu > button:hover:not(:disabled) { + background: color-mix(in srgb, currentColor 9%, transparent); +} + +#main #player #story-menu > button:disabled { + opacity: 0.45; + cursor: default; +} + +#main #player #audio-settings-anchor { + margin-top: 4px; + padding: 9px 8px 6px; + border-top: 1px solid color-mix(in srgb, var(--game-text, #000000) 12%, transparent); +} + +#main #player #audio-settings-anchor[hidden] { + display: none; +} + +#main #player .audio-control { + display: flex; + align-items: center; + gap: 8px; +} + +#main #player .audio-control[hidden] { + display: none; +} + +#main #player .audio-control button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + margin: 0; + padding: 5px; + border: 0; + border-radius: 8px; + background: transparent; + color: inherit; + cursor: pointer; +} + +#main #player .audio-control button:hover { + background: color-mix(in srgb, currentColor 9%, transparent); +} + +#main #player .audio-control input[type="range"] { + flex: 1 1 auto; + min-width: 0; + width: 100%; + margin: 0; + accent-color: var(--game-choice, #0066cc); + cursor: pointer; +} + +#main #player .audio-control.muted .wave { + display: none; +} + +#main #player .audio-control:not(.muted) .slash { + display: none; +} + +/* ---- Story image ----------------------------------------------------------- */ + +#main #player .story-visual { + position: relative; + flex: 0 0 27.169%; + min-height: 0; + overflow: hidden; + background: color-mix(in srgb, var(--game-bg, #ffffff) 80%, var(--game-text, #000000)); +} + +#main #player .story-visual[hidden] { + display: none; +} + +#main #player .story-visual img { + display: block; + width: 100%; + height: 100%; + max-width: none; + margin: 0; + border-radius: 0; + object-fit: cover; + object-position: center; +} + +/* ---- Narrative ------------------------------------------------------------- */ + +#main #player .story-passage { + position: relative; + z-index: 1; + box-sizing: border-box; + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + margin-bottom: var(--cue-strip, 44px); + padding: calc(var(--frame-pad) * 1.2) var(--frame-pad) var(--frame-pad); + overflow-x: hidden; + overflow-y: auto; + /* The editor breaks a word that reaches its right edge, and the frame has to do the same: + `overflow: hidden` above would otherwise cut the rest of the word away. Inherited by every + line in the passage, so a wrapped row always starts under the line above it. */ + overflow-wrap: anywhere; + overscroll-behavior: contain; + scrollbar-width: none; + justify-content: safe center; + font-size: calc(var(--game-font-size, 18px) * var(--player-zoom, 1)); + /* The top padding covers the scrolled history (see playerView.js). */ + -webkit-mask-image: linear-gradient(to bottom, transparent 0, transparent var(--story-top-cover, 0px), #000 var(--story-top-cover, 0px)); + mask-image: linear-gradient(to bottom, transparent 0, transparent var(--story-top-cover, 0px), #000 var(--story-top-cover, 0px)); +} + +#main #player .story-passage::-webkit-scrollbar { + display: none; +} + +/* Scroll room so a short new turn can still start at the top (see playerView.js). */ +#main #player .story-passage::after { + content: ""; + flex: 0 0 auto; + height: var(--story-scroll-fill, 0px); +} + +/* ---- Choices footer -------------------------------------------------------- */ + +/* Choices stay in a footer at the bottom of the frame: the narrative scrolls above them, + the choices never move. */ +#main #player .story-choices { + position: relative; + z-index: 2; + box-sizing: border-box; + flex: 0 0 auto; + max-height: 52%; + padding: 0 var(--frame-pad) calc(var(--frame-pad) * 0.6); + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--game-text, #000000) 48%, transparent) transparent; + font-size: calc(var(--game-font-size, 18px) * var(--player-zoom, 1)); + background: var(--game-bg, #ffffff); +} + +#main #player .story-choices::-webkit-scrollbar { + width: 6px; +} + +#main #player .story-choices::-webkit-scrollbar-track { + background: transparent; +} + +#main #player .story-choices::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--game-text, #000000) 48%, transparent); + border-radius: 999px; +} + +#main #player .story-choices[hidden] { + display: none; +} + +/* An unobtrusive bottom fade shows when more choices are available below. + It is an overlay on the frame, not another row that steals passage height. */ +#main #player .innerText.container.choices-more-below::before { + content: ""; + position: absolute; + z-index: 3; + inset: auto 0 0; + height: 22px; + pointer-events: none; + background: linear-gradient(to bottom, transparent, color-mix(in srgb, var(--game-bg, #ffffff) 83%, transparent)); +} + +/* The footer supplies its own separation from the text. */ +#main #player .innerText.container.has-choices .story-passage { + margin-bottom: 0; +} + +#main #player .innerText.container.has-choices .scroll-cue { + display: none; +} + +#main #player .story-choices .choice:first-child { + border-top: 1px solid color-mix(in srgb, var(--game-text, #000000) 14%, transparent); +} + +/* A passage above the limit is the writer's mistake: say so in the frame. */ +#main #player .story-choices .choice-limit { + margin: 0; + padding: 0.7em 0.75em; + color: #b3261e; + font-size: 0.85em; + line-height: 1.4; +} + +/* When a scene has #bg, keep the image visible all the way behind the choices. + Each option becomes a separate frosted-glass surface instead of a white footer. */ +#main #player .innerText.container.has-background .story-choices { + padding: clamp(8px, 1.8cqmin, 15px) var(--frame-pad) clamp(10px, 2cqmin, 16px); + border: 0; +} + +#main #player .innerText.container.has-background .story-choices .choice { + margin: 0; + padding: 0; + border: 0; +} + +#main #player .innerText.container.has-background .story-choices .choice + .choice { + margin-top: clamp(6px, 1cqmin, 9px); +} + +#main #player .innerText.container.has-background .story-choices .choice:first-child { + border-top: 0; +} + +#main #player .innerText.container.has-background .story-choices .choice .choice-button, +#main #player .innerText.container.has-background .story-choices .choice .unclickable { + box-sizing: border-box; + border: 1px solid color-mix(in srgb, var(--game-text, #000000) 20%, transparent); + border-radius: 11px; + background: color-mix(in srgb, var(--game-bg, #ffffff) 84%, transparent); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18); + -webkit-backdrop-filter: blur(14px) saturate(1.15); + backdrop-filter: blur(14px) saturate(1.15); +} + +#main #player .innerText.container.has-background .story-choices .choice .choice-button { + padding: 0.8em 2.5em 0.8em 0.95em; +} + +#main #player .innerText.container.has-background .story-choices .choice .choice-button:hover, +#main #player .innerText.container.has-background .story-choices .choice .choice-button:focus-visible, +#main #player .innerText.container.has-background .story-choices .choice .choice-button:active { + border-color: color-mix(in srgb, var(--game-choice-hover, #004c99) 52%, transparent); + background: color-mix(in srgb, var(--game-choice-hover, #004c99) 14%, transparent); + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.2), inset 3px 0 0 var(--game-choice-hover, #004c99); + color: var(--game-choice-hover, #004c99); +} + +#main #player .innerText.container.has-background .story-choices .choice .unclickable { + display: block; + padding: 0.8em 0.95em; +} + +/* Without a picture there is no band: the text starts at the top and uses the whole frame. */ +#main #player .innerText:not(.has-visual) .story-passage { + padding-top: clamp(52px, 8cqmin, 76px); + justify-content: flex-start; +} + +/* With a picture, every passage starts just below it. A scene title is a normal + passage element, so its presence must not control the position of the body text. */ +#main #player .innerText.has-visual .story-passage { + padding-top: 15px; + justify-content: flex-start; +} + +#main #player .story-passage img { + margin: 0.5em 0; + border-radius: calc(var(--frame-radius) / 2); +} + +/* Choices carry their own spacing (main.css), as in the exported page. The narrative + lines themselves carry none: they stack the way the editor stacks them, and the + writer spaces them with line-height. */ +#main #player .story-passage p.storyText { + margin: 0; +} + +#main #player .story-passage > .sceneTitle:not(:first-child), +#main #player .story-passage > .scene-title:not(:first-child) { + margin-top: 1.3em; +} + +/* Photon hides overflow on every heading. In the flex passage that makes the scene title + shrink below its own text (clipping it) instead of scrolling, as the exported page does. */ +#main #player .story-passage h1, +#main #player .story-passage h2, +#main #player .story-passage h3, +#main #player .story-passage h4, +#main #player .story-passage h5, +#main #player .story-passage h6 { + overflow: visible; +} + +/* ---- Format-driven composition --------------------------------------------- */ + +#main #player[data-story-format="vertical"] .innerText.container { + width: min( + calc(100cqw - 2 * var(--frame-gap)), + var(--game-width, 42rem), + calc((100cqh - 2 * var(--frame-gap)) * var(--game-story-aspect-ratio-number, 1)) + ); + height: auto; + aspect-ratio: var(--game-story-aspect-ratio, 1 / 1); +} + +/* ---- Scroll cue ------------------------------------------------------------ */ + +#main #player .innerText.container::after { + content: ""; + position: absolute; + z-index: 1; + inset: auto 0 0; + height: 2.4em; + background: linear-gradient(to bottom, transparent, var(--game-bg, #ffffff)); + opacity: 0; + pointer-events: none; + transition: opacity 0.2s; +} + +#main #player .innerText.container.has-more-below::after { + opacity: 0.85; +} + +#main #player .scroll-cue { + position: absolute; + z-index: 2; + left: 50%; + bottom: clamp(6px, 1.2cqmin, 12px); + display: grid; + place-items: center; + width: 32px; + height: 32px; + margin-left: -16px; + color: var(--game-text, #000000); + opacity: 0; + pointer-events: none; + transition: opacity 0.25s; +} + +#main #player .scroll-cue svg { + width: 24px; + height: 24px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +#main #player .innerText.container.has-more-below .scroll-cue { + opacity: 0.8; + pointer-events: auto; + cursor: pointer; +} + +/* The cue has its own strip under the text: it never covers a choice. */ +#main #player .innerText.container::after { + bottom: var(--cue-strip, 44px); +} + +/* The title screen owns the whole frame; no gameplay overlay may show through. */ +#main #player .innerText.container.title-screen-active .story-hud, +#main #player .innerText.container.title-screen-active .scroll-cue { + display: none; +} + +#main #player .innerText.container.title-screen-active::after { + opacity: 0; +} + +/* The export keeps the passage hidden until "Start", so the frame takes the title + screen's own height instead of the story's. */ +#main #player .innerText.container.title-screen-active > :not(.title-screen) { + display: none; +} + +/* "controls.show: false" hides the menu, as in the export. */ +#main #player.game-controls-hidden .story-hud { + display: none; +} + +@media (prefers-reduced-motion: reduce) { + #main #player .innerText.container::after, + #main #player .scroll-cue { + transition: none; + } +} + +@media (max-width: 520px) { + #main #player .scrollContainer { + --frame-gap: 8px; + } + + #main #player .story-hud { + padding: 10px 12px; + } + + #main #player .story-passage { + padding: 16px 18px; + } + + #main #player .innerText:not(.has-visual) .story-passage { + padding-top: 52px; + } +} + +/* ---- Pages ----------------------------------------------------------------- + A passage taller than the frame is divided into pages (see pager.js) instead of scrolling. + The navigation row sits between the text and the choices, which stay where they are. */ + +#main #player .page-out { + display: none !important; +} + +#main #player .story-pager { + position: relative; + z-index: 2; + box-sizing: border-box; + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5em; + padding: 0.3em var(--frame-pad) 0.5em; + background: var(--game-bg, #ffffff); +} + +#main #player .story-pager[hidden] { + display: none; +} + +#main #player .story-pager .pager-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2em; + height: 2em; + padding: 0; + border: 0; + border-radius: 50%; + background: transparent; + color: var(--game-text, #000000); + cursor: pointer; +} + +#main #player .story-pager .pager-button svg { + width: 1.2em; + height: 1.2em; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +#main #player .story-pager .pager-button:disabled { + opacity: 0.25; + cursor: default; +} + +#main #player .story-pager .pager-dots { + display: inline-flex; + align-items: center; + gap: 0.55em; + color: color-mix(in srgb, var(--game-text, #000000) 55%, transparent); + font-size: 0.8em; +} + +#main #player .story-pager .pager-dot { + width: 0.5em; + height: 0.5em; + box-sizing: border-box; + border: 1px solid currentColor; + border-radius: 50%; +} + +#main #player .story-pager .pager-dot.current { + background: currentColor; +} + +/* Until the last page the choices are shown but cannot be picked. */ +#main #player .story-choices.pages-locked { + opacity: 0.4; + pointer-events: none; +} + +/* Pages never scroll, and the text keeps the same place on every page. */ +#main #player .is-paged .story-passage { + overflow-y: hidden; + justify-content: flex-start; + margin-bottom: 0; +} + +#main #player .is-paged .story-passage.page-overflow { + overflow-y: auto; +} + +#main #player .is-paged .story-passage::after { + display: none; +} + +#main #player .is-paged .scroll-cue { + display: none; +} + +/* Keep the theme switch available when save/restart buttons are disabled. */ +#main #player.game-theme-only #story-menu #rewind, +#main #player.game-theme-only #story-menu #save, +#main #player.game-theme-only #story-menu #reload { display: none; } + + +/* Inky story ending: quiet, theme-aware ornament and optional caption. */ +.story-passage .story-ending { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.65em; + max-width: 100%; + margin: 2.2em auto 1em; + padding: 0.4em 0 0.2em; + color: inherit; + opacity: 0.82; + font: inherit; + text-align: center; + user-select: none; +} +.story-passage .story-ending[hidden] { display: none !important; } +/* Draw actual paths instead of flex pseudo-elements: no collapsed rules. */ +.story-passage .story-ending .story-ending-ornament { + display: block; + flex: none; + width: min(12em, 78%); + height: auto; + overflow: visible; + color: inherit; +} +.story-ending-line { + fill: none; + stroke: currentColor; + stroke-width: 1; + opacity: 0.72; +} +.story-ending-diamond { + fill: currentColor; +} +.story-ending-label { font-size: 0.8em; line-height: 1.35; overflow-wrap: anywhere; } diff --git a/app/renderer/gameChrome.js b/app/renderer/gameChrome.js new file mode 100644 index 00000000..44f75756 --- /dev/null +++ b/app/renderer/gameChrome.js @@ -0,0 +1,277 @@ +"use strict"; + +// Game chrome: the single menu that floats over the story frame. The title and the author +// belong to the title screen only; they are not repeated while the story is played. +// The web export template uses the same markup and the same ids; the Preview inserts it into +// the visible frame and moves it when the compiler swaps buffers. Behaviour that only exists +// in one environment (the export's saved game, the Preview's replay) is supplied by callbacks +// from the host, so the markup and the menu never fork. + +const InkyPager = require("./pager.js"); + +const MENU_ICON = ''; +const REWIND_ICON = ''; +const SAVE_ICON = ''; +const THEME_ICON = ''; +const AUDIO_ICON = ''; + +function hudHtml() { + return '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
'; +} + +function scrollCueHtml() { + return ''; +} + +function directChild(element, className) { + for( let child = element.firstElementChild; child; child = child.nextElementSibling ) { + if( child.classList.contains(className) ) return child; + } + return null; +} + +// The frame owns the picture band, the scrolling passage and the choices footer that stays +// at the bottom of the frame. Older preview markup (and the test fixtures) only had the +// paragraph buffer, so existing children move into the passage. +function ensureStructure(frame) { + if( !frame || !frame.classList ) return null; + frame.classList.add("container"); + let visual = directChild(frame, "story-visual"); + if( !visual ) { + visual = frame.ownerDocument.createElement("div"); + visual.className = "story-visual"; + visual.hidden = true; + frame.insertBefore(visual, frame.firstChild); + } + let passage = directChild(frame, "story-passage"); + if( !passage ) { + passage = frame.ownerDocument.createElement("div"); + passage.className = "story-passage"; + Array.from(frame.childNodes).forEach(node => { + if( node === visual || node.nodeType !== 1 ) return; + if( node.classList.contains("story-hud") || node.classList.contains("scroll-cue") || + node.classList.contains("title-screen") ) return; + passage.appendChild(node); + }); + frame.appendChild(passage); + } + // The page navigation sits between the passage and the choices (see pager.js). + let pager = directChild(frame, "story-pager"); + if( !pager ) { + pager = InkyPager.navElement(frame.ownerDocument); + passage.insertAdjacentElement("afterend", pager); + } + if( !directChild(frame, "story-choices") ) { + const choices = frame.ownerDocument.createElement("div"); + choices.className = "story-choices"; + choices.hidden = true; + pager.insertAdjacentElement("afterend", choices); + } + return passage; +} + +function pagerIn(frame) { + if( !frame ) return null; + ensureStructure(frame); + return directChild(frame, "story-pager"); +} + +function choicesIn(frame) { + if( !frame ) return null; + ensureStructure(frame); + return directChild(frame, "story-choices"); +} + +function createHud(frame) { + const holder = frame.ownerDocument.createElement("div"); + holder.innerHTML = hudHtml(); + return holder.firstElementChild; +} + +function createScrollCue(frame) { + const holder = frame.ownerDocument.createElement("div"); + holder.innerHTML = scrollCueHtml(); + return holder.firstElementChild; +} + +function activeFrame(player) { + return player ? player.querySelector(".innerText.active") : null; +} + +let installedFor = null; +let actions = {}; +// What the menu's volume row shows. The host (the Preview) supplies it; the row stays hidden +// until the story is known to use sound, as in the exported page. +let audioState = { available: false, level: 50, silent: false }; + +function closeMenu(player) { + const panel = player.querySelector("#story-menu"); + const toggle = player.querySelector("#menu-toggle"); + if( panel ) panel.hidden = true; + if( toggle ) toggle.setAttribute("aria-expanded", "false"); +} + +function hasSaved(available) { + return typeof available === "function" ? !!available() : !!available; +} + +function applyAudio(hud) { + const anchor = hud.querySelector("#audio-settings-anchor"); + const control = hud.querySelector("#audio-control"); + if( anchor ) anchor.hidden = !audioState.available; + if( control ) { + control.hidden = !audioState.available; + control.classList.toggle("muted", !!audioState.silent); + } + const toggle = hud.querySelector("#audio-toggle"); + if( toggle ) toggle.setAttribute("aria-pressed", String(!audioState.silent)); + const slider = hud.querySelector("#audio-volume"); + if( slider && slider.value !== String(audioState.level) ) slider.value = String(audioState.level); +} + +// The switch shows only when the writer allowed it, and names the theme it leads to +function applyThemeToggle(player, hud) { + const button = hud.querySelector("#theme-toggle"); + if( !button ) return; + const on = player.dataset.themeToggle === "1"; + button.hidden = !on; + if( !on ) return; + const target = player.dataset.themeTarget === "dark" ? "dark" : "light"; + const label = target === "dark" ? "Dark theme" : "Light theme"; + const text = button.querySelector("span"); + if( text && text.textContent !== label ) text.textContent = label; + button.title = label; + button.setAttribute("aria-label", "Change to " + label.toLowerCase()); +} + +function sync(player) { + if( !player ) return; + const frame = activeFrame(player); + if( !frame ) return; + ensureStructure(frame); + + let hud = player.querySelector(".story-hud"); + if( !hud ) hud = createHud(frame); + if( hud.parentElement !== frame || frame.firstElementChild !== hud ) frame.insertBefore(hud, frame.firstElementChild); + + let cue = player.querySelector(".scroll-cue"); + if( !cue ) cue = createScrollCue(frame); + if( cue.parentElement !== frame ) frame.appendChild(cue); + + const themeOnly = player.dataset.controlsShow === "0" && player.dataset.themeToggle === "1"; + player.classList.toggle("game-controls-hidden", player.dataset.controlsShow === "0" && !themeOnly); + player.classList.toggle("game-theme-only", themeOnly); + + const available = hasSaved(actions.hasSave); + player.dataset.titleSaveAvailable = available ? "1" : "0"; + const titleLoad = player.querySelector("#title-load"); + if( titleLoad ) titleLoad.disabled = !available; + applyThemeToggle(player, hud); + applyAudio(hud); + + if( installedFor && installedFor.lastFrame && installedFor.lastFrame !== frame ) closeMenu(player); + if( installedFor ) installedFor.lastFrame = frame; +} + +function setup(player, hostActions) { + if( !player || installedFor === player ) return; + installedFor = player; + actions = hostActions || {}; + + player.addEventListener("click", event => { + const target = event.target; + // The arrow takes the reader to the end of the text. + const cue = target.closest(".scroll-cue"); + if( cue ) { + const cueFrame = cue.closest(".innerText"); + const cuePassage = cueFrame ? cueFrame.querySelector(".story-passage") : null; + if( cuePassage ) { + const reduceMotion = player.ownerDocument.defaultView.matchMedia && player.ownerDocument.defaultView.matchMedia("(prefers-reduced-motion: reduce)").matches; + const fill = parseFloat(player.ownerDocument.defaultView.getComputedStyle(cuePassage).getPropertyValue("--story-scroll-fill")) || 0; + cuePassage.scrollTo({ top: Math.max(0, cuePassage.scrollHeight - fill), behavior: reduceMotion ? "auto" : "smooth" }); + } + return; + } + const toggle = target.closest("#menu-toggle"); + if( toggle ) { + const panel = player.querySelector("#story-menu"); + if( !panel ) return; + const shouldOpen = panel.hidden; + panel.hidden = !shouldOpen; + toggle.setAttribute("aria-expanded", String(shouldOpen)); + return; + } + if( !target.closest("#story-menu") ) closeMenu(player); + if( target.closest("#audio-toggle") ) { if( actions.onToggleMute ) actions.onToggleMute(); return; } + if( target.closest("#rewind") ) { closeMenu(player); if( actions.onRestart ) actions.onRestart(); return; } + if( target.closest("#save") ) { closeMenu(player); if( actions.onSave ) actions.onSave(); return; } + if( target.closest("#title-load") ) { + if( !target.closest("#title-load").disabled && actions.onLoad ) actions.onLoad(); + return; + } + if( target.closest("#theme-toggle") ) { + closeMenu(player); + if( player.dataset.themeToggle === "1" && actions.onToggleTheme ) actions.onToggleTheme(); + return; + } + if( target.closest("#story-menu > button") ) closeMenu(player); + }); + + player.addEventListener("input", event => { + const slider = event.target.closest ? event.target.closest("#audio-volume") : null; + if( slider && actions.onVolume ) actions.onVolume(Number(slider.value)); + }); + + player.ownerDocument.addEventListener("click", event => { + if( !player.contains(event.target) ) closeMenu(player); + }); + + player.ownerDocument.addEventListener("keydown", event => { + if( event.key !== "Escape" ) return; + const panel = player.querySelector("#story-menu"); + if( !panel || panel.hidden ) return; + closeMenu(player); + const toggle = player.querySelector("#menu-toggle"); + if( toggle ) toggle.focus(); + }); +} + +function setSaveAvailable(available) { + actions.hasSave = typeof available === "function" ? available : () => !!available; + if( installedFor ) sync(installedFor); +} + +function setAudioState(state) { + audioState = Object.assign({}, audioState, state || {}); + if( installedFor ) sync(installedFor); +} + +exports.GameChrome = { + html: hudHtml, + scrollCueHtml: scrollCueHtml, + ensureStructure: ensureStructure, + choicesIn: choicesIn, + pagerIn: pagerIn, + setup: setup, + sync: sync, + close: closeMenu, + setSaveAvailable: setSaveAvailable, + setAudioState: setAudioState +}; diff --git a/app/renderer/gameSettings.css b/app/renderer/gameSettings.css new file mode 100644 index 00000000..a29c19bc --- /dev/null +++ b/app/renderer/gameSettings.css @@ -0,0 +1,235 @@ +:root { + --bg: #f6f6f6; + --panel: #ffffff; + --text: #1d1d1f; + --muted: #6b6b70; + --border: #d9d9de; + --accent: #0066CC; + --danger: #c0392b; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #232326; + --panel: #2d2d31; + --text: #ececee; + --muted: #a0a0a8; + --border: #424248; + --accent: #66B2FF; + --danger: #ff7b6b; + } +} + +* { box-sizing: border-box; } +html, body { width: 100%; height: 100%; min-height: 0; overflow: hidden; } +body { + margin: 0; + /* The form lives inside Inky's editor pane; provide breathing room on all + sides so the first/last preset and focus outlines are never clipped. */ + padding: 14px 18px 10px; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden; + background: var(--bg); + color: var(--text); + font: 14px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", sans-serif; +} +header { min-width: 0; } +#settings-scroll { + display: block; + min-height: 0; + width: 100%; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + scrollbar-width: auto; + scrollbar-color: var(--muted) transparent; + padding: 8px 5px 16px; +} +#settings-scroll::-webkit-scrollbar { width: 10px; } +#settings-scroll::-webkit-scrollbar-thumb { + background: var(--muted); + border: 3px solid transparent; + border-radius: 8px; + background-clip: padding-box; +} +#settings-scroll::-webkit-scrollbar-track { background: transparent; } + +header h1 { font-size: 20px; margin: 2px 0 4px 0; } +.hint, .note { color: var(--muted); font-size: 12.5px; margin: 4px 0 8px 0; } +code { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.95em; } + +h2 { + font-size: 11.5px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); + margin: 22px 0 8px 0; +} + +section { margin: 0 0 12px; width: 100%; } +[hidden] { display: none !important; } + +/* Core layout for the Visual settings form. The same pane is used by both + tabs and can be wider than the old form, so let fields use the space rather + than leaving controls bunched together on the left. */ +.row { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + min-height: 36px; + margin: 8px 0; +} +.row > label:first-child:not(.inline) { + flex: 0 0 clamp(118px, 19%, 164px); + color: var(--text); +} +.row input[type="text"] { + flex: 1 1 150px; + width: auto; + min-width: 0; + padding: 5px 8px; +} +.row select { + flex: 1 1 220px; + width: auto; + min-width: 0; +} +.row input[type="color"] { flex: 0 0 36px; width: 36px; } +.row > label.inline { flex: 0 0 auto; } +.row > label.inline:only-child { flex: 1 1 auto; white-space: normal; } +.row > .segmented { flex: 1 1 280px; width: auto; } +.row > .segmented label { flex: 1 1 0; text-align: center; } +.range-row { + display: grid; + grid-template-columns: clamp(118px, 19%, 164px) minmax(0, 1fr) 64px 32px auto; + column-gap: 12px; +} +.range-row > label:first-child:not(.inline) { grid-column: 1; } +.range-control { display: contents; } +.range-control input[type="range"] { grid-column: 2; } +.range-control input[type="number"] { grid-column: 3; } +.range-control .unit { grid-column: 4; } +.range-row > .inline { grid-column: 5; } +input, select, button { font: inherit; } +input[type="text"], input[type="number"], select { + color: var(--text); + background: var(--panel); + border: 1px solid var(--border); + border-radius: 4px; + min-height: 28px; +} +input[type="color"] { + width: 32px; + height: 28px; + padding: 2px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--panel); +} +input[type="range"] { width: 100%; min-width: 0; accent-color: var(--accent); } +input[type="number"] { + width: 100%; + min-width: 0; + padding: 3px 5px; +} +.range-control .unit { width: 32px; color: var(--muted); font-size: 12px; text-align: center; } +.clear-text-button { + flex: 0 0 28px; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--muted); + background: var(--panel); + line-height: 1; + cursor: pointer; +} +.clear-text-button:hover, .clear-text-button:focus-visible { + border-color: var(--accent); + color: var(--text); +} +.text-button { flex: 0 0 auto; padding: 4px 9px; border: 1px solid var(--border); border-radius: 4px; color: var(--text); background: var(--panel); cursor: pointer; } +.text-button:hover:not(:disabled), .text-button:focus-visible { border-color: var(--accent); } +.text-button:disabled { color: var(--muted); cursor: default; } +.inline { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; font-size: 12px; } + +/* Cover matches the available width of the Title and Author fields. + The path and Choose button are separate sibling controls inside a flex + group; Clear remains the last element of the row, aligned with the + clear buttons above. No thumbnail or overlapping button is needed. */ +.row > .cover-input-field { + display: flex; + align-items: center; + gap: 12px; + flex: 1 1 150px; + min-width: 0; +} +.row .cover-input-field > input[type="text"] { + flex: 1 1 150px; + width: auto; + min-width: 0; +} +.cover-input-field > #choose-story-cover { + flex: 0 0 auto; + white-space: nowrap; +} +.segmented { display: flex; flex-wrap: nowrap; max-width: 100%; gap: 2px; padding: 2px; border: 1px solid var(--border); border-radius: 6px; background: var(--border); } +#theme { width: min(100%, 460px); } +#theme label { flex: 1 1 0; text-align: center; } +.segmented label { position: relative; margin: 0; cursor: pointer; } +.segmented input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + opacity: 0; + pointer-events: none; +} +.segmented span { display: block; padding: 5px 12px; background: var(--panel); color: var(--text); white-space: nowrap; } +.segmented span { border-radius: 4px; } +.segmented input:checked + span { background: var(--accent); color: #fff; } +.segmented input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: -2px; } +.chips { display: flex; flex-wrap: wrap; align-items: stretch; gap: 10px; width: 100%; padding: 3px; margin: 0 -3px; } +.chip { display: flex; flex: 1 1 calc(20% - 10px); flex-direction: column; align-items: center; justify-content: center; gap: 2px; min-width: 74px; max-width: 190px; min-height: 62px; margin: 1px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 7px; cursor: pointer; color: var(--text); } +.chip b { font-size: 19px; line-height: 1.1; } +.chip small { font-size: 11px; } +.chip.active { outline: 2px solid var(--accent); outline-offset: 1px; } +footer { display: flex; gap: 10px; min-width: 0; padding: 12px 0 2px; border-top: 1px solid var(--border); background: var(--bg); } +footer button { padding: 6px 10px; border: 1px solid var(--border); border-radius: 5px; color: var(--text); background: var(--panel); cursor: pointer; } +footer button:hover { border-color: var(--accent); } +.invalid { border-color: var(--danger) !important; } +input:disabled, select:disabled { opacity: .55; } + +.warnings { + margin: 12px 0 0 0; + padding: 10px 12px; + border: 1px solid var(--danger); + border-radius: 8px; + color: var(--danger); + font-size: 12.5px; +} +.warnings ul { margin: 4px 0 0 0; padding-left: 18px; } + + +/* Narrow or short editor panes: keep every control reachable and avoid + turning the settings page into a blank clipped viewport. */ +@media (max-width: 620px) { + body { padding-left: 12px; padding-right: 12px; } + .row { flex-wrap: wrap; gap: 6px; } + .row > label:first-child:not(.inline) { flex: 0 0 92px; } + .row input[type="text"], .row select { flex: 1 1 125px; width: auto; } + .range-row { + grid-template-columns: 92px minmax(0, 1fr) 60px 32px auto; + column-gap: 6px; + } + .range-row > label:first-child:not(.inline) { flex: initial; } + .row input[type="number"] { width: 100%; } + .row > .segmented { flex: 1 1 180px; } + input[type="range"] { min-width: 90px; } + .segmented span { padding-left: 9px; padding-right: 9px; } + footer { margin-left: 0; margin-right: 0; padding-left: 0; padding-right: 0; } +} diff --git a/app/renderer/gameSettings.html b/app/renderer/gameSettings.html new file mode 100644 index 00000000..09d51479 --- /dev/null +++ b/app/renderer/gameSettings.html @@ -0,0 +1,174 @@ + + + + + Game settings + + + +
+

Game settings

+

Changes update the preview right away and are stored in this project’s game.yaml.

+
+ +
+ + +
+

Presets

+
+
+ +
+

Theme

+
+ + +
+
+ +
+

Colors

+
+
+
+
+
+
+
+ +
+
+ +
+

Text

+
+ +
+
+
+ + + px +
+
+
+ + + × +
+
+
+ + + rem +
+
+
+ +
+

Story information

+
+
+ + + px +
+
+ +
+
+ +
+
+
+ + +
+
+
+ +
+

Text styling

+

Titles

+

Scene

+
+
+
+
+ + + px +
+
+ +
+
+
+ + + + +
+
+ +
+

Alignment

+
+
+ + + +
+
+
+ + + +
+
+ +
+

Sound

+
+
+ + + % +
+
+
+ + + % +
+
+
+ + + % +
+
+ +
+

Exported page

+
+
+ +
+ +
+ +
+ + + + diff --git a/app/renderer/gameSettings.js b/app/renderer/gameSettings.js new file mode 100644 index 00000000..ace59cd1 --- /dev/null +++ b/app/renderer/gameSettings.js @@ -0,0 +1,666 @@ +// The "Game settings" form. It shows the model of game.yaml, and saves it back +// (see gameStyle.js) each time something changes. + +const fs = require("fs"); +const path = require("path"); +const { ipcRenderer } = require("electron"); +const { GameStyle: G } = require("../gameStyle.js"); +const { COVER_EXTENSIONS, IMAGE_FOLDER, LARGE_IMAGE_BYTES } = require("../imageTypes.js"); + +const params = new URLSearchParams(location.search); +const yamlPath = params.get("path"); +const parentId = params.get("parent"); +const virtualMode = params.get("virtual") === "1"; +const initialYaml = params.get("yaml"); + +const $ = id => document.getElementById(id); +const FONT_NAME = /^[\p{L}\p{N} .\-']{1,60}$/u; +const ASPECT_RATIO = /^([0-9]+(?:\.[0-9]+)?)\s*[:/]\s*([0-9]+(?:\.[0-9]+)?)$/; + +let model = G.defaultModel(); +let customFontMode = false; +let saveTimer = null; + +// ---- helpers ------------------------------------------------------------ + +// Any CSS color to "#rrggbb", for +const ctx = document.createElement("canvas").getContext("2d"); +function toHex(color) { + ctx.fillStyle = "#000000"; + ctx.fillStyle = color; + const v = ctx.fillStyle; + return /^#[0-9a-f]{6}$/i.test(v) ? v : "#000000"; +} + +function fontStackFor(family) { + if( family === "inherit" ) return "inherit"; + return G.FONT_STACKS[family] || `"${family}", ${G.FONT_STACKS.system}`; +} + +function normalizeAspectRatio(value) { + const match = String(value == null ? "" : value).trim().match(ASPECT_RATIO); + if( !match ) return null; + const width = Number(match[1]); + const height = Number(match[2]); + if( !Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0 ) return null; + return `${String(width)}:${String(height)}`; +} + +async function populateSystemFontOptions() { + const selectors = [ + $("font-family"), + $("scene-title-family") + ]; + try { + const fonts = await ipcRenderer.invoke("list-system-fonts"); + selectors.forEach(select => { + Array.from(select.options).forEach(option => { + if( G.FONT_STACKS[option.value] ) option.style.fontFamily = G.FONT_STACKS[option.value]; + }); + const customOption = select.querySelector('option[value="custom"]'); + const builtInNames = new Set(Array.from(select.options).map(option => option.value.toLowerCase())); + fonts.forEach(name => { + if( builtInNames.has(name.toLowerCase()) ) return; + const option = document.createElement("option"); + option.value = name; + option.textContent = name; + option.style.fontFamily = `"${name}"`; + select.insertBefore(option, customOption); + builtInNames.add(name.toLowerCase()); + }); + }); + render(); + } catch(error) { + console.error("Could not load installed fonts in Game settings", error); + window.alert("Could not load installed system fonts: " + error.message); + } +} + +// The project folder of a saved story; null while the story is still untitled. +function projectFolder() { + return yamlPath && path.isAbsolute(yamlPath) ? path.dirname(yamlPath) : null; +} + +// The frame the cover will be cropped to: the display format's own proportion, +// or none when the format follows the text (then the picture keeps its proportion). +function coverFrameRatio() { + const format = model.layout.storyFormat; + if( format === "vertical" ) return "9 / 16"; + if( format === "landscape" ) return "16 / 9"; + if( format === "square" ) return "1 / 1"; + if( format === "custom" ) { + const ratio = normalizeAspectRatio(model.layout.customAspectRatio); + if( ratio ) return ratio.replace(":", " / "); + } + return null; +} + +let coverThumbToken = 0; + +function showCoverThumbMessage(box, image, note, message) { + box.dataset.state = "empty"; + image.removeAttribute("src"); + note.textContent = message; +} + +// Bringing a picture chosen outside the project into it: "/image/", +// with confirmation before an existing file is replaced. Returns { reference } or { error }. +const NOT_A_COVER = "Choose a cover image (JPG, JPEG, PNG, WebP, GIF or SVG)."; + +function copyCoverIntoProject(projectDir, source) { + const fail = error => ({ error: error }); + if( !path.isAbsolute(projectDir) || !fs.existsSync(projectDir) ) + return fail("Save your story first, so the cover can be stored in the project folder."); + const name = G.normalizeCoverReference(path.basename(source)); + if( !name ) return fail(NOT_A_COVER); + const destination = path.join(projectDir, IMAGE_FOLDER, name); + if( !G.pathStaysInsideRoot(projectDir, destination) ) + return fail("The cover must stay inside the project folder."); + if( fs.existsSync(destination) && + !window.confirm(`${IMAGE_FOLDER}/${name} already exists in the project. Replace it?`) ) + return { error: "" }; // cancelled: the field keeps the cover it already had + let bytes = 0; + try { + bytes = fs.statSync(source).size; + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.copyFileSync(source, destination); + } catch(error) { + return fail("The cover could not be copied into the project: " + (error.message || error)); + } + if( bytes > LARGE_IMAGE_BYTES ) + window.alert(`${IMAGE_FOLDER}/${name} was imported, but it is large (${(bytes / (1024 * 1024)).toFixed(1)} MB). The exported game carries every byte of it.`); + return { reference: G.coverReferenceForFile(projectDir, destination) }; +} + +function showWarnings(list) { + const box = $("warnings"); + if( !list || list.length === 0 ) { + box.hidden = true; + box.textContent = ""; + return; + } + box.hidden = false; + box.textContent = ""; + const intro = document.createElement("div"); + intro.textContent = "game.yaml had problems. Whatever could not be read uses the default, and saving here replaces the file:"; + box.appendChild(intro); + const ul = document.createElement("ul"); + list.forEach(w => { + const li = document.createElement("li"); + li.textContent = w; + ul.appendChild(li); + }); + box.appendChild(ul); +} + + +// ---- saving ------------------------------------------------------------- + +function writeNow(notifyParent = true) { + clearTimeout(saveTimer); + saveTimer = null; + const yaml = G.toYaml(model); + if( virtualMode ) { + // Before the first project save, game.yaml exists as an in-memory + // project document. Keep that document and the live preview in sync; + // InkProject.save() will persist it beside the new .ink file. + if( notifyParent && window.parent && window.parent !== window ) { + window.parent.postMessage({ type: "game-style-virtual-update", path: "game.yaml", yaml: yaml }, "*"); + } + showWarnings([]); + return; + } + if( !yamlPath ) return; + fs.writeFileSync(yamlPath, yaml, "utf8"); + // In the integrated editor, notify the parent page directly so it can + // reload the matching Ace document as well as refreshing the preview. + if( window.parent && window.parent !== window ) { + if( notifyParent ) window.parent.postMessage({ type: "game-style-reload", path: yamlPath }, "*"); + } else if( notifyParent ) { + ipcRenderer.send("game-settings-changed", parentId); + } + showWarnings([]); // the file was rewritten from the form +} + +// Parent calls this same-origin bridge before revealing the raw YAML editor. +// It flushes the debounce synchronously and returns exactly the bytes stored in game.yaml. +window.flushSettingsForAdvanced = function() { + writeNow(false); + return G.toYaml(model); +}; + +function scheduleSave() { + clearTimeout(saveTimer); + saveTimer = null; + // Keep an unsaved project's in-memory game.yaml current before the writer + // can press Save; on-disk projects retain the small debounce for dragging. + if( virtualMode ) writeNow(); + else saveTimer = setTimeout(writeNow, 120); +} + +// The parent asks for a synchronous flush before revealing the raw YAML editor. +// This avoids a delayed form save overwriting text typed immediately after the toggle. +window.addEventListener("message", event => { + const data = event.data; + if( !data ) return; + if( data.type === "game-settings-reload" && typeof data.yaml === "string" ) { + clearTimeout(saveTimer); + saveTimer = null; + const r = G.parseText(data.yaml); + model = r.model; + showWarnings(r.warnings); + render(); + updatePresetHighlight(); + return; + } + if( data.type !== "game-settings-flush" || window.parent === window ) return; + // For a rename flush, persist the form values before moving the file but do + // not ask the parent to reload the old path: the parent will rename and + // reopen the form on its new path immediately after this acknowledgement. + writeNow(!data.suppressReload); + window.parent.postMessage({ type: "game-settings-flushed", path: yamlPath, requestId: data.requestId }, "*"); +}); + +window.addEventListener("beforeunload", () => { if( saveTimer ) writeNow(); }); + +// The visual settings are represented by controls, not a fictional story preview. + +function sameColor(a, b) { + return String(a).toLowerCase() === String(b).toLowerCase(); +} + +function updatePresetHighlight() { + document.querySelectorAll(".chip").forEach(chip => { + const preset = G.PRESETS.find(p => p.id === chip.dataset.preset); + const c = model.colors; + const active = preset.theme === model.theme + && preset.font === model.font.family + && sameColor(preset.colors.background, c.background) + && sameColor(preset.colors.text, c.text) + && sameColor(preset.colors.choice, c.choice) + && !c.choiceHover; + chip.classList.toggle("active", active); + }); +} + +function setRangeValue(id, value) { + const range = $(id); + range.value = String(value); + const number = $(`${id}-number`); + number.value = range.value; + number.classList.remove("invalid"); +} + +function wireRange(id, onChange) { + const range = $(id); + const number = $(`${id}-number`); + const update = () => { + const value = Number(range.value); + number.value = range.value; + number.classList.remove("invalid"); + onChange(value); + }; + range.addEventListener("input", update); + number.addEventListener("input", () => { + const value = number.valueAsNumber; + if( number.value === "" || !Number.isFinite(value) + || value < Number(range.min) || value > Number(range.max) + || !number.validity.valid ) { + number.classList.add("invalid"); + return; + } + range.value = String(value); + number.value = range.value; + number.classList.remove("invalid"); + onChange(Number(range.value)); + }); + number.addEventListener("blur", () => { + if( number.classList.contains("invalid") ) { + number.value = range.value; + number.classList.remove("invalid"); + } + }); +} + +function render() { + document.querySelector(`input[name="theme"][value="${model.theme}"]`).checked = true; + + const c = model.colors; + ["background", "text", "choice"].forEach(key => { + $("hex-" + key).value = c[key]; + $("hex-" + key).classList.remove("invalid"); + $("pick-" + key).value = toHex(c[key]); + }); + const auto = !c.choiceHover; + $("hover-auto").checked = auto; + $("hex-hover").disabled = auto; + $("pick-hover").disabled = auto; + $("hex-hover").value = auto ? "" : c.choiceHover; + $("hex-hover").placeholder = auto ? "automatic" : ""; + $("hex-hover").classList.remove("invalid"); + $("pick-hover").value = toHex(auto ? c.choice : c.choiceHover); + + const hasFontOption = select => Array.from(select.options) + .some(option => option.value === model.font.family); + const isCustom = !hasFontOption($("font-family")); + customFontMode = isCustom; + $("font-family").value = isCustom ? "custom" : model.font.family; + $("font-custom").hidden = !customFontMode; + if( isCustom ) $("font-custom").value = model.font.family; + $("font-family").style.fontFamily = fontStackFor(model.font.family); + + setRangeValue("font-size", model.font.size); + setRangeValue("line-height", model.layout.lineHeight); + const full = model.layout.width === "full"; + $("width-full").checked = full; + $("width").disabled = full; + $("width-number").disabled = full; + if( !full ) setRangeValue("width", model.layout.width); + + const textAlignment = document.querySelector(`input[name="align"][value="${model.layout.align}"]`); + const choiceAlignment = document.querySelector(`input[name="choice-align"][value="${model.layout.choiceAlign}"]`); + if( textAlignment ) textAlignment.checked = true; + if( choiceAlignment ) choiceAlignment.checked = true; + $("controls-show").checked = model.controls.show; + ["master", "music", "sfx"].forEach(category => setRangeValue(`audio-${category}`, model.audio[category])); + $("story-title").value = model.titles.story; + setRangeValue("story-title-size", model.titles.storySize); + $("story-author").value = model.titles.author; + $("story-cover").value = model.titles.cover; + $("story-cover").classList.remove("invalid"); + $("choose-story-cover").disabled = !yamlPath; + + const sceneStyle = model.titles.sceneStyle; + const sceneFontSelect = $("scene-title-family"); + const sceneIsCustom = Object.prototype.hasOwnProperty.call(G.FONT_STACKS, sceneStyle.family) === false + && sceneStyle.family !== "inherit" + && !Array.from(sceneFontSelect.options).some(option => option.value === sceneStyle.family); + sceneFontSelect.value = sceneIsCustom ? "custom" : sceneStyle.family; + sceneFontSelect.style.fontFamily = fontStackFor(sceneStyle.family); + $("scene-title-custom").hidden = !sceneIsCustom; + if( sceneIsCustom ) $("scene-title-custom").value = sceneStyle.family; + setRangeValue("scene-title-size", sceneStyle.size); + const sceneColorDefault = !sceneStyle.color; + $("scene-title-color-auto").checked = sceneColorDefault; + $("scene-title-color").disabled = sceneColorDefault; + $("scene-title-pick").disabled = sceneColorDefault; + $("scene-title-color").value = sceneColorDefault ? "" : sceneStyle.color; + $("scene-title-color").placeholder = sceneColorDefault ? "default" : ""; + $("scene-title-color").classList.remove("invalid"); + $("scene-title-pick").value = toHex(sceneStyle.color || model.colors.text); + const sceneAlignment = document.querySelector(`input[name="scene-title-align"][value="${sceneStyle.align}"]`); + if( sceneAlignment ) sceneAlignment.checked = true; + + updatePresetHighlight(); +} + +// Something changed in the form: show it, and save +function changed() { + updatePresetHighlight(); + scheduleSave(); +} + +// ---- wiring ------------------------------------------------------------- + +function buildPresets() { + G.PRESETS.forEach(p => { + const b = document.createElement("button"); + b.type = "button"; + b.className = "chip"; + b.dataset.preset = p.id; + b.style.background = p.colors.background; + b.style.color = p.colors.text; + b.style.fontFamily = G.FONT_STACKS[p.font]; + b.innerHTML = 'Aa'; + b.querySelector("small").textContent = p.label; + b.addEventListener("click", () => { + model = G.applyPreset(model, p.id); + customFontMode = false; + render(); + scheduleSave(); + }); + $("presets").appendChild(b); + }); +} + +function wire() { + document.querySelectorAll('input[name="theme"]').forEach(r => r.addEventListener("change", () => { + if( !r.checked ) return; + model.theme = r.value; + // each theme starts from its own colors + const base = G.PALETTES[r.value === "dark" ? "dark" : "light"]; + model.colors = { background: base.background, text: base.text, choice: base.choice, choiceHover: null }; + render(); + scheduleSave(); + })); + + ["background", "text", "choice"].forEach(key => { + $("pick-" + key).addEventListener("input", () => { + model.colors[key] = $("pick-" + key).value; + $("hex-" + key).value = model.colors[key]; + $("hex-" + key).classList.remove("invalid"); + changed(); + }); + $("hex-" + key).addEventListener("input", () => { + const v = $("hex-" + key).value.trim(); + if( G.isColor(v) ) { + model.colors[key] = v; + $("hex-" + key).classList.remove("invalid"); + $("pick-" + key).value = toHex(v); + changed(); + } else { + $("hex-" + key).classList.add("invalid"); + } + }); + }); + + $("hover-auto").addEventListener("change", () => { + model.colors.choiceHover = $("hover-auto").checked ? null : toHex(model.colors.choice); + render(); + scheduleSave(); + }); + $("pick-hover").addEventListener("input", () => { + model.colors.choiceHover = $("pick-hover").value; + $("hex-hover").value = model.colors.choiceHover; + $("hex-hover").classList.remove("invalid"); + changed(); + }); + $("hex-hover").addEventListener("input", () => { + const v = $("hex-hover").value.trim(); + if( G.isColor(v) ) { + model.colors.choiceHover = v; + $("hex-hover").classList.remove("invalid"); + $("pick-hover").value = toHex(v); + changed(); + } else { + $("hex-hover").classList.add("invalid"); + } + }); + + $("font-family").addEventListener("change", () => { + const v = $("font-family").value; + if( v === "custom" ) { + customFontMode = true; + $("font-custom").hidden = false; + $("font-custom").focus(); + const typed = $("font-custom").value.trim(); + if( FONT_NAME.test(typed) ) { model.font.family = typed; changed(); } + } else { + customFontMode = false; + model.font.family = v; + $("font-custom").hidden = true; + changed(); + } + }); + $("font-custom").addEventListener("input", () => { + const v = $("font-custom").value.trim(); + if( FONT_NAME.test(v) ) { + model.font.family = v.replace(/'/g, ""); + $("font-custom").classList.remove("invalid"); + changed(); + } else { + $("font-custom").classList.add("invalid"); + } + }); + + wireRange("font-size", value => { + model.font.size = value; + changed(); + }); + wireRange("line-height", value => { + model.layout.lineHeight = Math.round(value * 100) / 100; + changed(); + }); + wireRange("width", value => { + $("width-full").checked = false; + model.layout.width = value; + changed(); + }); + $("width-full").addEventListener("change", () => { + model.layout.width = $("width-full").checked ? "full" : Number($("width").value); + $("width").disabled = $("width-full").checked; + $("width-number").disabled = $("width-full").checked; + changed(); + }); + + document.querySelectorAll('input[name="align"]').forEach(r => r.addEventListener("change", () => { + if( r.checked ) { model.layout.align = r.value; changed(); } + })); + document.querySelectorAll('input[name="choice-align"]').forEach(r => r.addEventListener("change", () => { + if( r.checked ) { model.layout.choiceAlign = r.value; changed(); } + })); + ["master", "music", "sfx"].forEach(category => { + wireRange(`audio-${category}`, value => { + model.audio[category] = Math.round(value); + changed(); + }); + }); + $("controls-show").addEventListener("change", () => { + model.controls.show = $("controls-show").checked; + changed(); + }); + + wireRange("story-title-size", value => { model.titles.storySize = value; changed(); }); + $("story-title").addEventListener("input", () => { + model.titles.story = $("story-title").value.trim(); + changed(); + }); + $("story-author").addEventListener("input", () => { + model.titles.author = $("story-author").value.trim(); + changed(); + }); + // The cover is saved when the field is left or Enter is pressed, not on every key + $("story-cover").addEventListener("change", () => { + const value = $("story-cover").value.trim(); + const reference = value ? G.normalizeCoverReference(value) : ""; + if( reference === null ) { + $("story-cover").classList.add("invalid"); + return; + } + $("story-cover").classList.remove("invalid"); + $("story-cover").value = reference; + model.titles.cover = reference; + changed(); + }); + $("choose-story-cover").addEventListener("click", async () => { + const projectDir = path.dirname(yamlPath); + const picked = await ipcRenderer.invoke("showOpenDialog", { + title: "Choose the cover image", + defaultPath: projectDir, + properties: ["openFile"], + filters: [{ name: "Images", extensions: COVER_EXTENSIONS.map(extension => extension.slice(1)) }] + }); + if( !picked || picked.canceled || !picked.filePaths || picked.filePaths.length === 0 ) return; + const source = picked.filePaths[0]; + let reference = G.coverReferenceForFile(projectDir, source); + // A picture outside the project is copied into its "image" folder, so the + // exported game can carry it; inside the project it is used where it is. + if( !reference ) { + const copied = copyCoverIntoProject(projectDir, source); + if( !copied.reference ) { + if( copied.error ) window.alert(copied.error); + return; + } + reference = copied.reference; + } + $("story-cover").value = reference; + $("story-cover").classList.remove("invalid"); + model.titles.cover = reference; + changed(); + }); + $("clear-story-cover").addEventListener("click", () => { + $("story-cover").value = ""; + $("story-cover").dispatchEvent(new Event("change", { bubbles: true })); + $("story-cover").focus(); + }); + $("clear-story-title").addEventListener("click", () => { + $("story-title").value = ""; + $("story-title").dispatchEvent(new Event("input", { bubbles: true })); + $("story-title").focus(); + }); + $("clear-story-author").addEventListener("click", () => { + $("story-author").value = ""; + $("story-author").dispatchEvent(new Event("input", { bubbles: true })); + $("story-author").focus(); + }); + + const sceneStyle = () => model.titles.sceneStyle; + const sceneStyleChanged = () => { + sceneStyle().configured = true; + changed(); + }; + $("scene-title-family").addEventListener("change", () => { + const style = sceneStyle(); + const value = $("scene-title-family").value; + if( value === "custom" ) { + $("scene-title-custom").hidden = false; + $("scene-title-custom").focus(); + const typed = $("scene-title-custom").value.trim(); + if( FONT_NAME.test(typed) ) { + style.family = typed.replace(/'/g, ""); + sceneStyleChanged(); + } + } else { + $("scene-title-custom").hidden = true; + style.family = value; + sceneStyleChanged(); + } + }); + $("scene-title-custom").addEventListener("input", () => { + const style = sceneStyle(); + const value = $("scene-title-custom").value.trim(); + if( FONT_NAME.test(value) ) { + style.family = value.replace(/'/g, ""); + $("scene-title-custom").classList.remove("invalid"); + sceneStyleChanged(); + } else { + $("scene-title-custom").classList.add("invalid"); + } + }); + wireRange("scene-title-size", value => { + sceneStyle().size = value; + sceneStyleChanged(); + }); + $("scene-title-color-auto").addEventListener("change", () => { + sceneStyle().color = $("scene-title-color-auto").checked ? null : toHex(model.colors.text); + render(); + sceneStyleChanged(); + }); + $("scene-title-pick").addEventListener("input", () => { + const style = sceneStyle(); + style.color = $("scene-title-pick").value; + $("scene-title-color").value = style.color; + $("scene-title-color").classList.remove("invalid"); + sceneStyleChanged(); + }); + $("scene-title-color").addEventListener("input", () => { + const style = sceneStyle(); + const value = $("scene-title-color").value.trim(); + if( G.isColor(value) ) { + style.color = value; + $("scene-title-color").classList.remove("invalid"); + $("scene-title-pick").value = toHex(value); + sceneStyleChanged(); + } else { + $("scene-title-color").classList.add("invalid"); + } + }); + document.querySelectorAll('input[name="scene-title-align"]').forEach(r => r.addEventListener("change", () => { + if( r.checked ) { + sceneStyle().align = r.value; + sceneStyleChanged(); + } + })); + + $("reset").addEventListener("click", () => { + model = G.defaultModel(); + customFontMode = false; + render(); + scheduleSave(); + }); +} + +// ---- start -------------------------------------------------------------- + +if( virtualMode ) { + try { + const r = G.parseText(initialYaml || G.templateYaml()); + model = r.model; + showWarnings(r.warnings); + } catch(e) { + showWarnings([`game.yaml could not be opened: ${e.message}`]); + } +} else if( yamlPath && fs.existsSync(yamlPath) ) { + try { + const r = G.parseText(fs.readFileSync(yamlPath, "utf8")); + model = r.model; + showWarnings(r.warnings); + } catch(e) { + showWarnings([`game.yaml could not be opened: ${e.message}`]); + } +} + +buildPresets(); +wire(); +render(); +populateSystemFontOptions(); diff --git a/app/renderer/gameSettings/gameSettings.css b/app/renderer/gameSettings/gameSettings.css new file mode 100644 index 00000000..751aa563 --- /dev/null +++ b/app/renderer/gameSettings/gameSettings.css @@ -0,0 +1,256 @@ +:root { + --bg: #f6f6f6; + --panel: #ffffff; + --text: #1d1d1f; + --muted: #6b6b70; + --border: #d9d9de; + --accent: #0066CC; + --danger: #c0392b; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #232326; + --panel: #2d2d31; + --text: #ececee; + --muted: #a0a0a8; + --border: #424248; + --accent: #66B2FF; + --danger: #ff7b6b; + } +} + +* { box-sizing: border-box; } +html, body { width: 100%; height: 100%; min-height: 0; overflow: hidden; } +body { + margin: 0; + /* The form lives inside Inky's editor pane; provide breathing room on all + sides so the first/last preset and focus outlines are never clipped. */ + padding: 14px 18px 10px; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden; + background: var(--bg); + color: var(--text); + font: 14px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", sans-serif; +} +header { min-width: 0; } +#settings-scroll { + display: block; + min-height: 0; + width: 100%; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + scrollbar-width: auto; + scrollbar-color: var(--muted) transparent; + padding: 8px 5px 16px; +} +#settings-scroll::-webkit-scrollbar { width: 10px; } +#settings-scroll::-webkit-scrollbar-thumb { + background: var(--muted); + border: 3px solid transparent; + border-radius: 8px; + background-clip: padding-box; +} +#settings-scroll::-webkit-scrollbar-track { background: transparent; } + +header h1 { font-size: 20px; margin: 2px 0 4px 0; } +.hint, .note { color: var(--muted); font-size: 12.5px; margin: 4px 0 8px 0; } +code { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.95em; } + +h2 { + font-size: 11.5px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); + margin: 22px 0 8px 0; +} + +section { margin: 0 0 12px; width: 100%; } +[hidden] { display: none !important; } + +/* Core layout for the Visual settings form. The same pane is used by both + tabs and can be wider than the old form, so let fields use the space rather + than leaving controls bunched together on the left. */ +.row { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + min-height: 36px; + margin: 8px 0; +} +.row > label:first-child:not(.inline) { + flex: 0 0 clamp(118px, 19%, 164px); + color: var(--text); +} +.row input[type="text"] { + flex: 1 1 150px; + width: auto; + min-width: 0; + padding: 5px 8px; +} +.row select { + flex: 1 1 220px; + width: auto; + min-width: 0; +} +.row input[type="color"] { flex: 0 0 36px; width: 36px; } +.row > label.inline { flex: 0 0 auto; } +.row > label.inline:only-child { flex: 1 1 auto; white-space: normal; } +.row > .segmented { flex: 1 1 280px; width: auto; } +.row > .segmented label { flex: 1 1 0; text-align: center; } +.range-row { + display: grid; + grid-template-columns: clamp(118px, 19%, 164px) minmax(0, 1fr) 64px 32px auto; + column-gap: 12px; +} +.range-row > label:first-child:not(.inline) { grid-column: 1; } +.range-control { display: contents; } +.range-control input[type="range"] { grid-column: 2; } +.range-control input[type="number"] { grid-column: 3; } +.range-control .unit { grid-column: 4; } +.range-row > .inline { grid-column: 5; } +input, select, button { font: inherit; } +input[type="text"], input[type="number"], select { + color: var(--text); + background: var(--panel); + border: 1px solid var(--border); + border-radius: 4px; + min-height: 28px; +} +input[type="color"] { + width: 32px; + height: 28px; + padding: 2px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--panel); +} +input[type="range"] { width: 100%; min-width: 0; accent-color: var(--accent); } +input[type="number"] { + width: 100%; + min-width: 0; + padding: 3px 5px; +} +.range-control .unit { width: 32px; color: var(--muted); font-size: 12px; text-align: center; } +.clear-text-button { + flex: 0 0 28px; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--muted); + background: var(--panel); + line-height: 1; + cursor: pointer; +} +.clear-text-button:hover, .clear-text-button:focus-visible { + border-color: var(--accent); + color: var(--text); +} +.text-button { flex: 0 0 auto; padding: 4px 9px; border: 1px solid var(--border); border-radius: 4px; color: var(--text); background: var(--panel); cursor: pointer; } +.text-button:hover:not(:disabled), .text-button:focus-visible { border-color: var(--accent); } +.text-button:disabled { color: var(--muted); cursor: default; } +.inline { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; font-size: 12px; } + +/* Cover matches the available width of the Title and Author fields. + The path and Choose button are separate sibling controls inside a flex + group; Clear remains the last element of the row, aligned with the + clear buttons above. No thumbnail or overlapping button is needed. */ +.row > .cover-input-field { + display: flex; + align-items: center; + gap: 12px; + flex: 1 1 150px; + min-width: 0; +} +.row .cover-input-field > input[type="text"] { + flex: 1 1 150px; + width: auto; + min-width: 0; +} +.cover-input-field > #choose-story-cover { + flex: 0 0 auto; + white-space: nowrap; +} +.segmented { display: flex; flex-wrap: nowrap; max-width: 100%; gap: 2px; padding: 2px; border: 1px solid var(--border); border-radius: 6px; background: var(--border); } +#theme { width: min(100%, 460px); } +#theme label { flex: 1 1 0; text-align: center; } +.segmented label { position: relative; margin: 0; cursor: pointer; } +.segmented input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + opacity: 0; + pointer-events: none; +} +.segmented span { display: block; padding: 5px 12px; background: var(--panel); color: var(--text); white-space: nowrap; } +.segmented span { border-radius: 4px; } +.segmented input:checked + span { background: var(--accent); color: #fff; } +.segmented input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: -2px; } +.chips { display: flex; flex-wrap: wrap; align-items: stretch; gap: 10px; width: 100%; padding: 3px; margin: 0 -3px; } +.chip { display: flex; flex: 1 1 calc(20% - 10px); flex-direction: column; align-items: center; justify-content: center; gap: 2px; min-width: 74px; max-width: 190px; min-height: 62px; margin: 1px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 7px; cursor: pointer; color: var(--text); } +.chip b { font-size: 19px; line-height: 1.1; } +.chip small { font-size: 11px; } +.chip.active { outline: 2px solid var(--accent); outline-offset: 1px; } +footer { display: flex; gap: 10px; min-width: 0; padding: 12px 0 2px; border-top: 1px solid var(--border); background: var(--bg); } +footer button { padding: 6px 10px; border: 1px solid var(--border); border-radius: 5px; color: var(--text); background: var(--panel); cursor: pointer; } +footer button:hover { border-color: var(--accent); } +.invalid { border-color: var(--danger) !important; } +input:disabled, select:disabled { opacity: .55; } + +.warnings { + margin: 12px 0 0 0; + padding: 10px 12px; + border: 1px solid var(--danger); + border-radius: 8px; + color: var(--danger); + font-size: 12.5px; +} +.warnings ul { margin: 4px 0 0 0; padding-left: 18px; } + + +/* Narrow or short editor panes: keep every control reachable and avoid + turning the settings page into a blank clipped viewport. */ +@media (max-width: 620px) { + body { padding-left: 12px; padding-right: 12px; } + .row { flex-wrap: wrap; gap: 6px; } + .row > label:first-child:not(.inline) { flex: 0 0 92px; } + .row input[type="text"], .row select { flex: 1 1 125px; width: auto; } + .range-row { + grid-template-columns: 92px minmax(0, 1fr) 60px 32px auto; + column-gap: 6px; + } + .range-row > label:first-child:not(.inline) { flex: initial; } + .row input[type="number"] { width: 100%; } + .row > .segmented { flex: 1 1 180px; } + input[type="range"] { min-width: 90px; } + .segmented span { padding-left: 9px; padding-right: 9px; } + footer { margin-left: 0; margin-right: 0; padding-left: 0; padding-right: 0; } +} + +/* Clear theme-mode settings, with no hidden export checkbox or preset-pair guessing. */ +.appearance-label { color: var(--text); font-size: 12px; margin: 14px 0 7px; } +/* Keep both segmented controls only as wide as their actual buttons. + In particular, Theme mode must not stretch into a 500px empty track. */ +#appearance > #theme-mode, +#appearance #edit-theme, +#presentation-settings #presentation-mode { + display: inline-flex; + width: max-content; + max-width: 100%; +} +.theme-pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-top: 12px; } +.theme-pair label { display: flex; flex-direction: column; gap: 6px; font-size: 12px; color: var(--text); } +.theme-pair select { width: 100%; min-height: 30px; padding: 4px 6px; } +.theme-sample { background: var(--sample-bg, #ffffff); color: var(--sample-text, #000000); border: 1px solid var(--border); border-radius: 8px; padding: 13px 16px; margin: 15px 0 6px; max-width: 520px; overflow-wrap: anywhere; } +.theme-sample strong { display: block; font-size: 18px; } +.theme-sample p { margin: 10px 0; line-height: 1.55; } +.theme-sample span { color: var(--sample-choice, #0066cc); } +.editing-look-label { font-weight: normal; font-size: 11px; letter-spacing: 0; text-transform: none; } +@media (max-width: 460px) { .theme-pair { grid-template-columns: 1fr; } } diff --git a/app/renderer/gameSettings/gameSettings.html b/app/renderer/gameSettings/gameSettings.html new file mode 100644 index 00000000..a6fa3824 --- /dev/null +++ b/app/renderer/gameSettings/gameSettings.html @@ -0,0 +1,222 @@ + + + + + Game settings + + + +
+

Game settings

+

Changes update the preview right away and are stored in this project’s game.yaml.

+
+ +
+ + +
+

Appearance

+

Theme mode

+
+ + +
+
+

Preset

+
+

The story uses one theme. The player cannot switch it.

+
+ +
+ Before Dawn

The star can warm your village or guide the travelers home.

+ Return its light. Guide the travelers. +
+
+ +
+

Story presentation

+
+ + +
+

Screen Mode starts a clean page at each # screen marker. Ink choices, variables and flow remain unchanged.

+
+ +
+

Story ending

+
+
+
+

The marker is shown after the final passage when the Ink story reaches END.

+
+
+
+

Play Again restarts from the Title Screen. Both options work independently in Preview and Web Export; neither changes the Ink narrative.

+
+ +
+

Colors

+
+
+
+
+
+
+
+ +
+
+ +
+

Text

+
+ +
+
+
+ + + px +
+
+
+ + + × +
+
+
+ + + rem +
+
+
+ +
+

Story information

+
+
+ + + px +
+
+ +
+
+ +
+
+
+ + +
+
+
+ +
+

Text styling

+

Titles

+

Scene

+
+
+
+
+ + + px +
+
+ +
+
+
+ + + + +
+
+ +
+

Alignment

+
+
+ + + +
+
+
+ + + +
+
+ +
+

Sound

+
+
+ + + % +
+
+
+ + + % +
+
+
+ + + % +
+
+ +
+

Exported page

+
+
+ +
+ +
+ +
+ + + + diff --git a/app/renderer/gameSettings/gameSettings.js b/app/renderer/gameSettings/gameSettings.js new file mode 100644 index 00000000..2fb35bf6 --- /dev/null +++ b/app/renderer/gameSettings/gameSettings.js @@ -0,0 +1,792 @@ +// The "Game settings" form. It shows the model of game.yaml, and saves it back +// (see gameStyle.js) each time something changes. + +const fs = require("fs"); +const path = require("path"); +const { ipcRenderer } = require("electron"); +const { GameStyle: G } = require("../gameStyle.js"); +const { COVER_EXTENSIONS, IMAGE_FOLDER, LARGE_IMAGE_BYTES } = require("../imageTypes.js"); + +const params = new URLSearchParams(location.search); +const yamlPath = params.get("path"); +const parentId = params.get("parent"); +const virtualMode = params.get("virtual") === "1"; +const initialYaml = params.get("yaml"); + +const $ = id => document.getElementById(id); +const FONT_NAME = /^[\p{L}\p{N} .\-']{1,60}$/u; + +let model = G.defaultModel(); +let customFontMode = false; +let editingTheme = "light"; +let saveTimer = null; + +// ---- story title and author: single plain spaces only (see GameStyle.normalizeTitleText) ---- + +function wireTitleField(id, store) { + const input = $(id); + // The model, game.yaml and preview always get the normalized text; the field itself may keep + // any number of spaces at the start and at the end. + const commit = () => { store(G.normalizeTitleText(input.value)); changed(); }; + + // A second space between words, or any other spacing character, is refused. + input.addEventListener("beforeinput", event => { + if( event.inputType !== "insertText" || !event.data || G.normalizeTitleText(event.data, true).trim() !== "" ) return; + const start = input.selectionStart, end = input.selectionEnd; + const result = input.value.slice(0, start) + event.data + input.value.slice(end); + if( result !== G.normalizeTitleText(result, true) ) event.preventDefault(); + }); + + // Pasted text is normalized, not refused. + input.addEventListener("paste", event => { + const text = event.clipboardData && event.clipboardData.getData("text"); + if( typeof text !== "string" ) return; + event.preventDefault(); + const start = input.selectionStart, end = input.selectionEnd; + const pasted = G.normalizeTitleText(text); + input.value = input.value.slice(0, start) + pasted + input.value.slice(end); + const caret = start + pasted.length; + input.setSelectionRange(caret, caret); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + // Whatever got through (deleting the middle of "A B C", dropped text, IME) is collapsed here, + // keeping the cursor at the same place in the text. + input.addEventListener("input", event => { + if( !event.isComposing ) { + const live = G.normalizeTitleText(input.value, true); + if( live !== input.value ) { + const caret = Math.min(live.length, G.normalizeTitleText(input.value.slice(0, input.selectionStart) + "x", true).length - 1); + input.value = live; + input.setSelectionRange(caret, caret); + } + } + commit(); + }); + input.addEventListener("compositionend", () => input.dispatchEvent(new Event("input", { bubbles: true }))); +} + +// The field shows the text of the model. The spaces at its start or end that the author typed are kept +// while the text is otherwise the same. +function showTitleText(id, text) { + const input = $(id); + if( G.normalizeTitleText(input.value) !== G.normalizeTitleText(text) ) input.value = G.normalizeTitleText(text); +} + +// ---- helpers ------------------------------------------------------------ + +// Any CSS color to "#rrggbb", for +const ctx = document.createElement("canvas").getContext("2d"); +function toHex(color) { + ctx.fillStyle = "#000000"; + ctx.fillStyle = color; + const v = ctx.fillStyle; + return /^#[0-9a-f]{6}$/i.test(v) ? v : "#000000"; +} + +function fontStackFor(family) { + if( family === "inherit" ) return "inherit"; + return G.FONT_STACKS[family] || `"${family}", ${G.FONT_STACKS.system}`; +} + +async function populateSystemFontOptions() { + const selectors = [ + $("font-family"), + $("scene-title-family") + ]; + try { + const fonts = await ipcRenderer.invoke("list-system-fonts"); + selectors.forEach(select => { + Array.from(select.options).forEach(option => { + if( G.FONT_STACKS[option.value] ) option.style.fontFamily = G.FONT_STACKS[option.value]; + }); + const customOption = select.querySelector('option[value="custom"]'); + const builtInNames = new Set(Array.from(select.options).map(option => option.value.toLowerCase())); + fonts.forEach(name => { + if( builtInNames.has(name.toLowerCase()) ) return; + const option = document.createElement("option"); + option.value = name; + option.textContent = name; + option.style.fontFamily = `"${name}"`; + select.insertBefore(option, customOption); + builtInNames.add(name.toLowerCase()); + }); + }); + render(); + } catch(error) { + console.error("Could not load installed fonts in Game settings", error); + window.alert("Could not load installed system fonts: " + error.message); + } +} + +// The project folder of a saved story; null while the story is still untitled. +function projectFolder() { + return yamlPath && path.isAbsolute(yamlPath) ? path.dirname(yamlPath) : null; +} + +// Bringing a picture chosen outside the project into it: "/image/", +// with confirmation before an existing file is replaced. Returns { reference } or { error }. +const NOT_A_COVER = "Choose a cover image (JPG, JPEG, PNG, WebP, GIF or SVG)."; + +function copyCoverIntoProject(projectDir, source) { + const fail = error => ({ error: error }); + if( !path.isAbsolute(projectDir) || !fs.existsSync(projectDir) ) + return fail("Save your story first, so the cover can be stored in the project folder."); + const name = G.normalizeCoverReference(path.basename(source)); + if( !name ) return fail(NOT_A_COVER); + const destination = path.join(projectDir, IMAGE_FOLDER, name); + if( !G.pathStaysInsideRoot(projectDir, destination) ) + return fail("The cover must stay inside the project folder."); + if( fs.existsSync(destination) && + !window.confirm(`${IMAGE_FOLDER}/${name} already exists in the project. Replace it?`) ) + return { error: "" }; // cancelled: the field keeps the cover it already had + let bytes = 0; + try { + bytes = fs.statSync(source).size; + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.copyFileSync(source, destination); + } catch(error) { + return fail("The cover could not be copied into the project: " + (error.message || error)); + } + if( bytes > LARGE_IMAGE_BYTES ) + window.alert(`${IMAGE_FOLDER}/${name} was imported, but it is large (${(bytes / (1024 * 1024)).toFixed(1)} MB). The exported game carries every byte of it.`); + return { reference: G.coverReferenceForFile(projectDir, destination) }; +} + +function showWarnings(list) { + const box = $("warnings"); + if( !list || list.length === 0 ) { + box.hidden = true; + box.textContent = ""; + return; + } + box.hidden = false; + box.textContent = ""; + const intro = document.createElement("div"); + intro.textContent = "game.yaml had problems. Whatever could not be read uses the default, and saving here replaces the file:"; + box.appendChild(intro); + const ul = document.createElement("ul"); + list.forEach(w => { + const li = document.createElement("li"); + li.textContent = w; + ul.appendChild(li); + }); + box.appendChild(ul); +} + + +// ---- saving ------------------------------------------------------------- + +function writeNow(notifyParent = true) { + clearTimeout(saveTimer); + saveTimer = null; + const yaml = G.toYaml(model); + if( virtualMode ) { + // Before the first project save, game.yaml exists as an in-memory + // project document. Keep that document and the live preview in sync; + // InkProject.save() will persist it beside the new .ink file. + if( notifyParent && window.parent && window.parent !== window ) { + window.parent.postMessage({ type: "game-style-virtual-update", path: "game.yaml", yaml: yaml, previewTheme: model.themeMode === "dual" ? editingTheme : model.theme }, "*"); + } + showWarnings([]); + return; + } + if( !yamlPath ) return; + fs.writeFileSync(yamlPath, yaml, "utf8"); + // In the integrated editor, notify the parent page directly so it can + // reload the matching Ace document as well as refreshing the preview. + if( window.parent && window.parent !== window ) { + if( notifyParent ) window.parent.postMessage({ type: "game-style-reload", path: yamlPath, previewTheme: model.themeMode === "dual" ? editingTheme : model.theme }, "*"); + } else if( notifyParent ) { + ipcRenderer.send("game-settings-changed", parentId); + } + showWarnings([]); // the file was rewritten from the form +} + +// Parent calls this same-origin bridge before revealing the raw YAML editor. +// It flushes the debounce synchronously and returns exactly the bytes stored in game.yaml. +window.flushSettingsForAdvanced = function() { + writeNow(false); + return G.toYaml(model); +}; + +function scheduleSave() { + clearTimeout(saveTimer); + saveTimer = null; + // Keep an unsaved project's in-memory game.yaml current before the writer + // can press Save; on-disk projects retain the small debounce for dragging. + if( virtualMode ) writeNow(); + else saveTimer = setTimeout(writeNow, 120); +} + +// The parent asks for a synchronous flush before revealing the raw YAML editor. +// This avoids a delayed form save overwriting text typed immediately after the toggle. +window.addEventListener("message", event => { + const data = event.data; + if( !data ) return; + if( data.type === "game-settings-reload" && typeof data.yaml === "string" ) { + clearTimeout(saveTimer); + saveTimer = null; + const r = G.parseText(data.yaml); + model = r.model; + editingTheme = model.theme; + showWarnings(r.warnings); + render(); + updatePresetHighlight(); + return; + } + if( data.type !== "game-settings-flush" || window.parent === window ) return; + // For a rename flush, persist the form values before moving the file but do + // not ask the parent to reload the old path: the parent will rename and + // reopen the form on its new path immediately after this acknowledgement. + writeNow(!data.suppressReload); + window.parent.postMessage({ type: "game-settings-flushed", path: yamlPath, requestId: data.requestId }, "*"); +}); + +window.addEventListener("beforeunload", () => { if( saveTimer ) writeNow(); }); + +// The visual settings are represented by controls, not a fictional story preview. + +function sameColor(a, b) { + return String(a).toLowerCase() === String(b).toLowerCase(); +} + +function editableLook() { + return model.themeMode === "dual" ? model.themes[editingTheme] + : { colors: model.colors, fontFamily: model.font.family }; +} +function setEditableColors(colors) { + if( model.themeMode === "dual" ) + G.setThemeLook(model, editingTheme, { colors, fontFamily: editableLook().fontFamily, fontSize: editableLook().fontSize }); + else model.colors = colors; +} +function setEditableFont(family) { + if( model.themeMode === "dual" ) + G.setThemeLook(model, editingTheme, { colors: editableLook().colors, fontFamily: family, fontSize: editableLook().fontSize }); + else model.font.family = family; +} +function setEditableSize(size) { + if( model.themeMode === "dual" ) { + const look = editableLook(); + G.setThemeLook(model, editingTheme, { colors: look.colors, fontFamily: look.fontFamily, fontSize: size }); + } else model.font.size = size; +} +function matchingPreset(side) { + const look = model.themeMode === "dual" ? model.themes[side] : editableLook(); + return G.PRESETS.find(p => p.theme === side + && p.font === look.fontFamily + && ["background", "text", "choice"].every(key => sameColor(p.colors[key], look.colors[key])) + && !look.colors.choiceHover); +} +function updatePresetHighlight() { + const look = editableLook(); + document.querySelectorAll(".chip").forEach(chip => { + const preset = G.PRESETS.find(p => p.id === chip.dataset.preset); + const active = preset.theme === model.theme + && preset.font === look.fontFamily + && ["background", "text", "choice"].every(key => sameColor(preset.colors[key], look.colors[key])) + && !look.colors.choiceHover; + chip.classList.toggle("active", active); + }); + for( const side of ["light", "dark"] ) { + const select = $(side + "-preset"); + const preset = matchingPreset(side); + select.value = preset ? preset.id : "custom"; + } + const c = look.colors; + const sample = $("theme-sample"); + sample.style.setProperty("--sample-bg", c.background); + sample.style.setProperty("--sample-text", c.text); + sample.style.setProperty("--sample-choice", c.choice); + sample.style.fontFamily = fontStackFor(look.fontFamily); +} +function showThemeInPreview() { + if( window.parent && window.parent !== window ) + window.parent.postMessage({ type: "game-settings-preview-theme", theme: model.themeMode === "dual" ? editingTheme : model.theme }, "*"); +} + +function setRangeValue(id, value) { + const range = $(id); + range.value = String(value); + const number = $(`${id}-number`); + number.value = range.value; + number.classList.remove("invalid"); +} + +function wireRange(id, onChange) { + const range = $(id); + const number = $(`${id}-number`); + const update = () => { + const value = Number(range.value); + number.value = range.value; + number.classList.remove("invalid"); + onChange(value); + }; + range.addEventListener("input", update); + number.addEventListener("input", () => { + const value = number.valueAsNumber; + if( number.value === "" || !Number.isFinite(value) + || value < Number(range.min) || value > Number(range.max) + || !number.validity.valid ) { + number.classList.add("invalid"); + return; + } + range.value = String(value); + number.value = range.value; + number.classList.remove("invalid"); + onChange(Number(range.value)); + }); + number.addEventListener("blur", () => { + if( number.classList.contains("invalid") ) { + number.value = range.value; + number.classList.remove("invalid"); + } + }); +} + +function renderAppearance() { + const dual = model.themeMode === "dual"; + const mode = document.querySelector(`input[name="theme-mode"][value="${model.themeMode}"]`); + if( mode ) mode.checked = true; + $("dual-appearance").hidden = !dual; + $("single-appearance").hidden = dual; + const current = document.querySelector(`input[name="edit-theme"][value="${editingTheme}"]`); + if( current ) current.checked = true; + $("editing-look-label").textContent = dual ? `(${editingTheme} theme)` : ""; + updatePresetHighlight(); +} + +function render() { + const selectedPresentation = document.querySelector(`input[name="presentation-mode"][value="${model.presentation || "traditional"}"]`); + if( selectedPresentation ) selectedPresentation.checked = true; + if( model.themeMode !== "dual" ) editingTheme = model.theme; + renderAppearance(); + const look = editableLook(); + const c = look.colors; + ["background", "text", "choice"].forEach(key => { + $("hex-" + key).value = c[key]; + $("hex-" + key).classList.remove("invalid"); + $("pick-" + key).value = toHex(c[key]); + }); + const auto = !c.choiceHover; + $("hover-auto").checked = auto; + $("hex-hover").disabled = auto; + $("pick-hover").disabled = auto; + $("hex-hover").value = auto ? "" : c.choiceHover; + $("hex-hover").placeholder = auto ? "automatic" : ""; + $("hex-hover").classList.remove("invalid"); + $("pick-hover").value = toHex(auto ? c.choice : c.choiceHover); + + const hasFontOption = select => Array.from(select.options) + .some(option => option.value === look.fontFamily); + const isCustom = !hasFontOption($("font-family")); + customFontMode = isCustom; + $("font-family").value = isCustom ? "custom" : look.fontFamily; + $("font-custom").hidden = !customFontMode; + if( isCustom ) $("font-custom").value = look.fontFamily; + $("font-family").style.fontFamily = fontStackFor(look.fontFamily); + + setRangeValue("font-size", model.themeMode === "dual" ? look.fontSize : model.font.size); + setRangeValue("line-height", model.layout.lineHeight); + const full = model.layout.width === "full"; + $("width-full").checked = full; + $("width").disabled = full; + $("width-number").disabled = full; + if( !full ) setRangeValue("width", model.layout.width); + + const textAlignment = document.querySelector(`input[name="align"][value="${model.layout.align}"]`); + const choiceAlignment = document.querySelector(`input[name="choice-align"][value="${model.layout.choiceAlign}"]`); + if( textAlignment ) textAlignment.checked = true; + if( choiceAlignment ) choiceAlignment.checked = true; + $("controls-show").checked = model.controls.show; + $("ending-show").checked = model.ending.show; + $("ending-text").value = model.ending.text; + $("ending-text").disabled = !model.ending.show; + $("ending-play-again-show").checked = model.ending.playAgainShow; + $("ending-play-again-text").value = model.ending.playAgainText; + $("ending-play-again-text").disabled = !model.ending.playAgainShow; + ["master", "music", "sfx"].forEach(category => setRangeValue(`audio-${category}`, model.audio[category])); + showTitleText("story-title", model.titles.story); + setRangeValue("story-title-size", model.titles.storySize); + showTitleText("story-author", model.titles.author); + $("story-cover").value = model.titles.cover; + $("story-cover").classList.remove("invalid"); + $("choose-story-cover").disabled = !yamlPath; + + const sceneStyle = model.titles.sceneStyle; + const sceneFontSelect = $("scene-title-family"); + const sceneIsCustom = Object.prototype.hasOwnProperty.call(G.FONT_STACKS, sceneStyle.family) === false + && sceneStyle.family !== "inherit" + && !Array.from(sceneFontSelect.options).some(option => option.value === sceneStyle.family); + sceneFontSelect.value = sceneIsCustom ? "custom" : sceneStyle.family; + sceneFontSelect.style.fontFamily = fontStackFor(sceneStyle.family); + $("scene-title-custom").hidden = !sceneIsCustom; + if( sceneIsCustom ) $("scene-title-custom").value = sceneStyle.family; + setRangeValue("scene-title-size", sceneStyle.size); + const sceneColorDefault = !sceneStyle.color; + $("scene-title-color-auto").checked = sceneColorDefault; + $("scene-title-color").disabled = sceneColorDefault; + $("scene-title-pick").disabled = sceneColorDefault; + $("scene-title-color").value = sceneColorDefault ? "" : sceneStyle.color; + $("scene-title-color").placeholder = sceneColorDefault ? "default" : ""; + $("scene-title-color").classList.remove("invalid"); + $("scene-title-pick").value = toHex(sceneStyle.color || editableLook().colors.text); + const sceneAlignment = document.querySelector(`input[name="scene-title-align"][value="${sceneStyle.align}"]`); + if( sceneAlignment ) sceneAlignment.checked = true; + + updatePresetHighlight(); +} + +// Something changed in the form: show it, and save +function changed() { + updatePresetHighlight(); + scheduleSave(); +} + +// ---- wiring ------------------------------------------------------------- + +function buildPresets() { + G.PRESETS.forEach(p => { + const b = document.createElement("button"); + b.type = "button"; + b.className = "chip"; + b.dataset.preset = p.id; + b.style.background = p.colors.background; + b.style.color = p.colors.text; + b.style.fontFamily = G.FONT_STACKS[p.font]; + b.innerHTML = 'Aa'; + b.querySelector("small").textContent = p.label; + b.addEventListener("click", () => { + model = G.applyPreset(model, p.id); + editingTheme = model.theme; + G.applyThemePreset(model, p.theme, p.id); + // In single mode selecting a family provides its sensible counterpart. + const other = p.theme === "light" ? "dark" : "light"; + G.applyThemePreset(model, other, G.FAMILIES[p.family][other]); + customFontMode = false; + render(); + scheduleSave(); + }); + $("presets").appendChild(b); + }); +} + +function wire() { + document.querySelectorAll('input[name="presentation-mode"]').forEach(r => r.addEventListener("change", () => { + if( !r.checked ) return; + model.presentation = r.value; + scheduleSave(); + })); + document.querySelectorAll('input[name="theme-mode"]').forEach(r => r.addEventListener("change", () => { + if( !r.checked ) return; + G.setThemeMode(model, r.value); + editingTheme = model.theme; + render(); + scheduleSave(); + showThemeInPreview(); + })); + for( const side of ["light", "dark"] ) { + $(side + "-preset").addEventListener("change", () => { + const preset = $(side + "-preset").value; + if( preset === "custom" ) return; + G.applyThemePreset(model, side, preset); + editingTheme = side; + render(); + scheduleSave(); + showThemeInPreview(); + }); + } + document.querySelectorAll('input[name="edit-theme"]').forEach(r => r.addEventListener("change", () => { + if( !r.checked ) return; + editingTheme = r.value; + render(); + showThemeInPreview(); + })); + + ["background", "text", "choice"].forEach(key => { + $("pick-" + key).addEventListener("input", () => { + setEditableColors(Object.assign({}, editableLook().colors, { [key]: $("pick-" + key).value })); + $("hex-" + key).value = editableLook().colors[key]; + $("hex-" + key).classList.remove("invalid"); + changed(); + }); + $("hex-" + key).addEventListener("input", () => { + const v = $("hex-" + key).value.trim(); + if( G.isColor(v) ) { + setEditableColors(Object.assign({}, editableLook().colors, { [key]: v })); + $("hex-" + key).classList.remove("invalid"); + $("pick-" + key).value = toHex(v); + changed(); + } else { + $("hex-" + key).classList.add("invalid"); + } + }); + }); + + $("hover-auto").addEventListener("change", () => { + setEditableColors(Object.assign({}, editableLook().colors, { choiceHover: $("hover-auto").checked ? null : toHex(editableLook().colors.choice) })); + render(); + scheduleSave(); + }); + $("pick-hover").addEventListener("input", () => { + setEditableColors(Object.assign({}, editableLook().colors, { choiceHover: $("pick-hover").value })); + $("hex-hover").value = editableLook().colors.choiceHover; + $("hex-hover").classList.remove("invalid"); + changed(); + }); + $("hex-hover").addEventListener("input", () => { + const v = $("hex-hover").value.trim(); + if( G.isColor(v) ) { + setEditableColors(Object.assign({}, editableLook().colors, { choiceHover: v })); + $("hex-hover").classList.remove("invalid"); + $("pick-hover").value = toHex(v); + changed(); + } else { + $("hex-hover").classList.add("invalid"); + } + }); + + $("font-family").addEventListener("change", () => { + const v = $("font-family").value; + if( v === "custom" ) { + customFontMode = true; + $("font-custom").hidden = false; + $("font-custom").focus(); + const typed = $("font-custom").value.trim(); + if( FONT_NAME.test(typed) ) { setEditableFont(typed); changed(); } + } else { + customFontMode = false; + setEditableFont(v); + $("font-custom").hidden = true; + changed(); + } + }); + $("font-custom").addEventListener("input", () => { + const v = $("font-custom").value.trim(); + if( FONT_NAME.test(v) ) { + setEditableFont(v.replace(/'/g, "")); + $("font-custom").classList.remove("invalid"); + changed(); + } else { + $("font-custom").classList.add("invalid"); + } + }); + + wireRange("font-size", value => { + setEditableSize(value); + changed(); + }); + wireRange("line-height", value => { + model.layout.lineHeight = Math.round(value * 100) / 100; + changed(); + }); + wireRange("width", value => { + $("width-full").checked = false; + model.layout.width = value; + changed(); + }); + $("width-full").addEventListener("change", () => { + model.layout.width = $("width-full").checked ? "full" : Number($("width").value); + $("width").disabled = $("width-full").checked; + $("width-number").disabled = $("width-full").checked; + changed(); + }); + + document.querySelectorAll('input[name="align"]').forEach(r => r.addEventListener("change", () => { + if( r.checked ) { model.layout.align = r.value; changed(); } + })); + document.querySelectorAll('input[name="choice-align"]').forEach(r => r.addEventListener("change", () => { + if( r.checked ) { model.layout.choiceAlign = r.value; changed(); } + })); + ["master", "music", "sfx"].forEach(category => { + wireRange(`audio-${category}`, value => { + model.audio[category] = Math.round(value); + changed(); + }); + }); + $("ending-show").addEventListener("change", () => { + model.ending.show = $("ending-show").checked; + $("ending-text").disabled = !model.ending.show; + changed(); + }); + $("ending-text").addEventListener("change", () => { + const value = $("ending-text").value.trim().replace(/[\x00-\x1f\x7f]/g, " "); + model.ending.text = value.slice(0, 60) || "The End"; + $("ending-text").value = model.ending.text; + changed(); + }); + $("ending-play-again-show").addEventListener("change", () => { + model.ending.playAgainShow = $("ending-play-again-show").checked; + $("ending-play-again-text").disabled = !model.ending.playAgainShow; + changed(); + }); + $("ending-play-again-text").addEventListener("change", () => { + const value = $("ending-play-again-text").value.trim().replace(/[\x00-\x1f\x7f]/g, " "); + model.ending.playAgainText = value.slice(0, 60) || "Play Again"; + $("ending-play-again-text").value = model.ending.playAgainText; + changed(); + }); + $("controls-show").addEventListener("change", () => { + model.controls.show = $("controls-show").checked; + changed(); + }); + + wireTitleField("story-title", value => { model.titles.story = value; }); + wireRange("story-title-size", value => { model.titles.storySize = value; changed(); }); + wireTitleField("story-author", value => { model.titles.author = value; }); + // The cover is saved when the field is left or Enter is pressed, not on every key + $("story-cover").addEventListener("change", () => { + const value = $("story-cover").value.trim(); + const reference = value ? G.normalizeCoverReference(value) : ""; + if( reference === null ) { + $("story-cover").classList.add("invalid"); + return; + } + $("story-cover").classList.remove("invalid"); + $("story-cover").value = reference; + model.titles.cover = reference; + changed(); + }); + $("choose-story-cover").addEventListener("click", async () => { + const projectDir = path.dirname(yamlPath); + const picked = await ipcRenderer.invoke("showOpenDialog", { + title: "Choose the cover image", + defaultPath: projectDir, + properties: ["openFile"], + filters: [{ name: "Images", extensions: COVER_EXTENSIONS.map(extension => extension.slice(1)) }] + }); + if( !picked || picked.canceled || !picked.filePaths || picked.filePaths.length === 0 ) return; + const source = picked.filePaths[0]; + let reference = G.coverReferenceForFile(projectDir, source); + // A picture outside the project is copied into its "image" folder, so the + // exported game can carry it; inside the project it is used where it is. + if( !reference ) { + const copied = copyCoverIntoProject(projectDir, source); + if( !copied.reference ) { + if( copied.error ) window.alert(copied.error); + return; + } + reference = copied.reference; + } + $("story-cover").value = reference; + $("story-cover").classList.remove("invalid"); + model.titles.cover = reference; + changed(); + }); + $("clear-story-cover").addEventListener("click", () => { + $("story-cover").value = ""; + $("story-cover").dispatchEvent(new Event("change", { bubbles: true })); + $("story-cover").focus(); + }); + $("clear-story-title").addEventListener("click", () => { + $("story-title").value = ""; + $("story-title").dispatchEvent(new Event("input", { bubbles: true })); + $("story-title").focus(); + }); + $("clear-story-author").addEventListener("click", () => { + $("story-author").value = ""; + $("story-author").dispatchEvent(new Event("input", { bubbles: true })); + $("story-author").focus(); + }); + + const sceneStyle = () => model.titles.sceneStyle; + const sceneStyleChanged = () => { + sceneStyle().configured = true; + changed(); + }; + $("scene-title-family").addEventListener("change", () => { + const style = sceneStyle(); + const value = $("scene-title-family").value; + if( value === "custom" ) { + $("scene-title-custom").hidden = false; + $("scene-title-custom").focus(); + const typed = $("scene-title-custom").value.trim(); + if( FONT_NAME.test(typed) ) { + style.family = typed.replace(/'/g, ""); + sceneStyleChanged(); + } + } else { + $("scene-title-custom").hidden = true; + style.family = value; + sceneStyleChanged(); + } + }); + $("scene-title-custom").addEventListener("input", () => { + const style = sceneStyle(); + const value = $("scene-title-custom").value.trim(); + if( FONT_NAME.test(value) ) { + style.family = value.replace(/'/g, ""); + $("scene-title-custom").classList.remove("invalid"); + sceneStyleChanged(); + } else { + $("scene-title-custom").classList.add("invalid"); + } + }); + wireRange("scene-title-size", value => { + sceneStyle().size = value; + sceneStyleChanged(); + }); + $("scene-title-color-auto").addEventListener("change", () => { + sceneStyle().color = $("scene-title-color-auto").checked ? null : toHex(editableLook().colors.text); + render(); + sceneStyleChanged(); + }); + $("scene-title-pick").addEventListener("input", () => { + const style = sceneStyle(); + style.color = $("scene-title-pick").value; + $("scene-title-color").value = style.color; + $("scene-title-color").classList.remove("invalid"); + sceneStyleChanged(); + }); + $("scene-title-color").addEventListener("input", () => { + const style = sceneStyle(); + const value = $("scene-title-color").value.trim(); + if( G.isColor(value) ) { + style.color = value; + $("scene-title-color").classList.remove("invalid"); + $("scene-title-pick").value = toHex(value); + sceneStyleChanged(); + } else { + $("scene-title-color").classList.add("invalid"); + } + }); + document.querySelectorAll('input[name="scene-title-align"]').forEach(r => r.addEventListener("change", () => { + if( r.checked ) { + sceneStyle().align = r.value; + sceneStyleChanged(); + } + })); + + $("reset").addEventListener("click", () => { + model = G.defaultModel(); + customFontMode = false; + render(); + scheduleSave(); + }); +} + +// ---- start -------------------------------------------------------------- + +if( virtualMode ) { + try { + const r = G.parseText(initialYaml || G.templateYaml()); + model = r.model; + showWarnings(r.warnings); + } catch(e) { + showWarnings([`game.yaml could not be opened: ${e.message}`]); + } +} else if( yamlPath && fs.existsSync(yamlPath) ) { + try { + const r = G.parseText(fs.readFileSync(yamlPath, "utf8")); + model = r.model; + showWarnings(r.warnings); + } catch(e) { + showWarnings([`game.yaml could not be opened: ${e.message}`]); + } +} + +editingTheme = model.theme; +buildPresets(); +wire(); +render(); +populateSystemFontOptions(); diff --git a/app/renderer/gameStyle.js b/app/renderer/gameStyle.js new file mode 100644 index 00000000..be20e8be --- /dev/null +++ b/app/renderer/gameStyle.js @@ -0,0 +1,1197 @@ +// Game style: the look of the story, read from "game.yaml" next to the story. +// +// One model, two renderers: the preview in the IDE and the exported web page. +// Everything is optional. Without a game.yaml the story gets the default look: +// white background, black text, system font, plain choices and no card. + +const fs = require("fs"); +const path = require("path"); +const { pathToFileURL } = require("url"); +const jsyaml = require("js-yaml"); +const { COVER_EXTENSIONS } = require("./imageTypes.js"); +const { TitleScreen } = require("./titleScreen.js"); +const { GameChrome } = require("./gameChrome.js"); + +const FILE_NAME = "game.yaml"; + +const FONT_STACKS = { + system: 'system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", "Helvetica Neue", Arial, sans-serif', + serif: 'Georgia, "Times New Roman", "Noto Serif", serif', + mono: 'ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace' +}; + +const PALETTES = { + light: { background: "#ffffff", text: "#000000", choice: "#0066CC" }, + dark: { background: "#282828", text: "#faf1c6", choice: "#66B2FF" } +}; + +// Theme families: each one pairs a light preset with a dark preset. The player's light/dark +// switch moves between the two presets of the family the writer chose. +const FAMILIES = { + "classic-night": { light: "classic", dark: "night" }, + "sepia-terminal": { light: "sepia", dark: "terminal" } +}; + +const PRESETS = [ + { id: "classic", label: "Classic", theme: "light", font: "system", family: "classic-night", colors: { background: "#ffffff", text: "#000000", choice: "#0066CC" } }, + { id: "night", label: "Night", theme: "dark", font: "system", family: "classic-night", colors: { background: "#282828", text: "#faf1c6", choice: "#66B2FF" } }, + { id: "sepia", label: "Sepia", theme: "light", font: "serif", family: "sepia-terminal", colors: { background: "#f4ecd8", text: "#433422", choice: "#8b4513" } }, + { id: "terminal", label: "Terminal", theme: "dark", font: "mono", family: "sepia-terminal", colors: { background: "#000000", text: "#33ff66", choice: "#ffd54a" } } +]; + +const KNOWN = { + "": ["theme", "theme_mode", "themes", "theme_family", "presentation", "ending", "colors", "font", "layout", "controls", "audio", "titles"], + "themes": ["light", "dark"], + "colors": ["background", "text", "choice", "choice_hover"], + "font": ["family", "size"], + // Keep the retired container, choice_style and display-format keys recognized so older projects remain warning-free. + "layout": ["line_height", "width", "align", "choice_align", "container", "choice_style", "story_format", "custom_aspect_ratio"], + "controls": ["show", "theme_toggle"], + "ending": ["show", "text", "play_again_show", "play_again_text"], + "audio": ["volume", "master", "music", "sfx"], + // Keep the retired story and author title styles recognized so older projects remain warning-free. + "titles": ["story", "author", "cover", "story_size", "story_size_px", "story_style", "author_style", "scene_style"] +}; + +const TEXT_ALIGNS = ["left", "center", "right"]; +// Titles can also be "auto": follow the text below them (the default). +const TITLE_ALIGNS = ["auto", "left", "center", "right"]; +const CHOICE_ALIGNS = ["left", "center", "right"]; +const TITLE_STYLE_KEYS = ["family", "size", "color", "align"]; + +function isObject(v) { + return v !== null && typeof v === "object" && !Array.isArray(v); +} + +function isColor(v) { + if( typeof v !== "string" ) return false; + v = v.trim(); + if( v.length === 0 || v.length > 40 || /[;{}<>\\]/.test(v) ) return false; + if( /^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(v) ) return true; + return typeof CSS !== "undefined" && CSS.supports("color", v); +} + +// In YAML an unquoted #ffffff is a comment, so the value would silently +// disappear. Quote bare hex colors so "background: #ffffff" just works. +// Story title and author: between the words only single plain spaces (U+0020) are allowed. Any run +// of whitespace, control or invisible characters (tab, line break, U+00A0, U+200B...) becomes one +// space, and the start and the end are trimmed. A field being edited may keep any number of plain +// spaces at the start and at the end (keepEnds): everything that reads the value ignores them. +// Letters, digits and symbols are never touched. +const TITLE_SEPARATORS = /[\s\u0000-\u001F\u007F-\u009F\u180E\u200B-\u200F\u2060-\u2064\u206A-\u206F\u3164\uFEFF]+/g; +function normalizeTitleText(text, keepEnds) { + const raw = String(text == null ? "" : text); + const trimmed = value => value.replace(TITLE_SEPARATORS, " ").replace(/^ /, "").replace(/ $/, ""); + if( !keepEnds ) return trimmed(raw); + const lead = raw.match(/^ */)[0]; + const rest = raw.slice(lead.length); + const trail = rest.match(/ *$/)[0]; + return lead + trimmed(rest.slice(0, rest.length - trail.length)) + trail; +} + +function quoteBareHexColors(text) { + return text.replace( + /^(\s*[A-Za-z_]+\s*:\s*)(#[0-9a-zA-Z]{3,12})(\s*(?:#.*)?)$/gm, + (m, before, color, after) => `${before}"${color}"${after}` + ); +} + +function checkKnownKeys(obj, group, w) { + Object.keys(obj).forEach(k => { + if( KNOWN[group].indexOf(k) == -1 ) { + const name = group ? `${group}.${k}` : k; + w.push(`Unknown option "${name}" was ignored.`); + } + }); +} + +function readGroup(raw, name, w) { + const g = raw[name]; + if( g === undefined || g === null ) return {}; + if( !isObject(g) ) { + w.push(`"${name}" should contain options, one per line, indented. It was ignored.`); + return {}; + } + checkKnownKeys(g, name, w); + return g; +} + +function pickNumber(group, groupName, key, min, max, fallback, w) { + const v = group[key]; + if( v === undefined || v === null ) return fallback; + if( typeof v === "number" && isFinite(v) && v >= min && v <= max ) return v; + w.push(`"${groupName}.${key}" must be a number between ${min} and ${max} (got "${v}"). Using ${fallback}.`); + return fallback; +} + +function pickChoice(group, groupName, key, allowed, fallback, w) { + const v = group[key]; + if( v === undefined || v === null ) return fallback; + const s = String(v).toLowerCase().trim(); + if( allowed.indexOf(s) != -1 ) return s; + w.push(`"${groupName}.${key}" must be one of: ${allowed.join(", ")} (got "${v}"). Using ${fallback}.`); + return fallback; +} + +// "image\\cover.JPG" becomes "image/cover.JPG". Anything that is not a picture path inside the +// project folder (a URL, an absolute path, "..") gives null. +function coverReference(value) { + const reference = String(value).trim().replace(/\\/g, "/"); + if( !reference || /[\x00-\x1f]/.test(reference) || /^[a-z][a-z0-9+.-]*:/i.test(reference) || reference.startsWith("/") ) return null; + if( reference.split("/").some(part => !part || part === "." || part === "..") ) return null; + return COVER_EXTENSIONS.includes(path.extname(reference).toLowerCase()) ? reference : null; +} + +// The cover file on disk, or null when it is not set, not a picture, outside the project or missing. +function resolveCoverFile(projectDir, reference) { + const safeReference = reference ? coverReference(reference) : null; + if( !projectDir || !safeReference ) return null; + const file = path.resolve(projectDir, ...safeReference.split("/")); + if( !pathStaysInsideRoot(projectDir, file) ) return null; + try { return fs.statSync(file).isFile() ? file : null; } + catch(e) { return null; } +} + +// What to write in game.yaml for a picture the writer chose, or null when it is outside the project. +function coverReferenceForFile(projectDir, file) { + return coverReference(path.relative(projectDir, file).split(path.sep).join("/")); +} + +function pickColor(group, key, fallback, w) { + const v = group[key]; + if( v === undefined || v === null ) return fallback; + if( isColor(v) ) return String(v).trim(); + w.push(`"colors.${key}" is not a valid color (got "${v}"). Use something like "#ffffff". Using the default.`); + return fallback; +} + +function pickTitleStyle(group, name, w, defaultSize) { + if( !isObject(group) ) { + if( group !== undefined && group !== null ) + w.push(`"${name}" should contain title options, one per line, indented. It was ignored.`); + group = {}; + } else { + Object.keys(group).forEach(k => { + if( TITLE_STYLE_KEYS.indexOf(k) === -1 ) + w.push(`Unknown option "${name}.${k}" was ignored.`); + }); + } + + let family = "inherit"; + const rawFamily = group.family; + if( rawFamily !== undefined && rawFamily !== null ) { + const key = String(rawFamily).toLowerCase().trim(); + if( key === "inherit" || Object.prototype.hasOwnProperty.call(FONT_STACKS, key) ) { + family = key; + } else if( /^[\p{L}\p{N} .\-']{1,60}$/u.test(String(rawFamily).trim()) ) { + family = String(rawFamily).trim().replace(/'/g, ""); + } else { + w.push(`"${name}.family" is not valid (got "${rawFamily}"). Using inherit.`); + } + } + + let color = null; + if( group.color !== undefined && group.color !== null ) { + if( isColor(group.color) ) color = String(group.color).trim(); + else w.push(`"${name}.color" is not a valid color (got "${group.color}"). Using the default.`); + } + + return { + family: family, + size: pickNumber(group, name, "size", 10, 100, defaultSize, w), + color: color, + align: pickChoice(group, name, "align", TITLE_ALIGNS, "auto", w), + configured: TITLE_STYLE_KEYS.some(key => group[key] !== undefined && group[key] !== null) + }; +} + +// A dual-theme story owns two complete, independently editable looks. Legacy style +// files use their root colors and font and derive the partner from theme_family. +function presetLook(theme, family) { + const familyPair = FAMILIES[family] || FAMILIES["classic-night"]; + const preset = PRESETS.find(p => p.id === familyPair[theme]); + return { colors: Object.assign({}, preset.colors, { choiceHover: null }), fontFamily: preset.font, fontSize: 18 }; +} +function copyLook(look) { + return { colors: Object.assign({}, look.colors), fontFamily: look.fontFamily, fontSize: look.fontSize }; +} +function readThemeLook(raw, name, fallback, w) { + if( raw === undefined || raw === null ) return copyLook(fallback); + if( !isObject(raw) ) { + w.push(`"themes.${name}" should contain colors and font options. Using the default.`); + return copyLook(fallback); + } + for( const key of Object.keys(raw) ) { + if( !["colors", "font", "size"].includes(key) ) w.push(`Unknown option "themes.${name}.${key}" was ignored.`); + } + let colors = {}; + if( raw.colors !== undefined && raw.colors !== null ) { + if( isObject(raw.colors) ) { + colors = raw.colors; + for( const key of Object.keys(colors) ) { + if( !KNOWN.colors.includes(key) ) w.push(`Unknown option "themes.${name}.colors.${key}" was ignored.`); + } + } else w.push(`"themes.${name}.colors" must contain named colors. Using the default.`); + } + const result = { colors: { + background: pickColor(colors, "background", fallback.colors.background, w), + text: pickColor(colors, "text", fallback.colors.text, w), + choice: pickColor(colors, "choice", fallback.colors.choice, w), + choiceHover: pickColor(colors, "choice_hover", fallback.colors.choiceHover, w) + }, fontFamily: fallback.fontFamily, + fontSize: pickNumber(raw, `themes.${name}`, "size", 10, 40, fallback.fontSize, w) }; + if( raw.font !== undefined && raw.font !== null ) { + const value = String(raw.font).trim(); + if( Object.prototype.hasOwnProperty.call(FONT_STACKS, value) || /^[\p{L}\p{N} .\-']{1,60}$/u.test(value) ) + result.fontFamily = value.replace(/'/g, ""); + else w.push(`"themes.${name}.font" is not a valid font. Using the default.`); + } + return result; +} + +// Turn what was read from the YAML into a complete, validated "model": the +// plain values of every option (what the settings form shows and saves). +// Never throws: invalid values are ignored and reported in "warnings". +function resolveModel(raw) { + const w = []; + if( raw !== undefined && raw !== null && !isObject(raw) ) { + w.push(`${FILE_NAME} should contain options, one per line ("theme: dark"). It was ignored.`); + } + raw = isObject(raw) ? raw : {}; + checkKnownKeys(raw, "", w); + + // The retired "auto" theme opens as light, without a warning + const theme = String(raw.theme).toLowerCase().trim() === "auto" + ? "light" + : pickChoice(raw, "", "theme", ["light", "dark"], "light", w); + const base = PALETTES[theme === "dark" ? "dark" : "light"]; + const themeFamily = pickChoice(raw, "", "theme_family", Object.keys(FAMILIES), "", w); + + const colors = readGroup(raw, "colors", w); + const font = readGroup(raw, "font", w); + const layout = readGroup(raw, "layout", w); + const controls = readGroup(raw, "controls", w); + const audio = readGroup(raw, "audio", w); + const titles = readGroup(raw, "titles", w); + const presentation = pickChoice(raw, "", "presentation", ["traditional", "screen"], "traditional", w); + const ending = readGroup(raw, "ending", w); + let endingShow = true; + if (ending.show !== undefined && ending.show !== null) { + if (typeof ending.show === "boolean") endingShow = ending.show; + else w.push('"ending.show" must be true or false. Using true.'); + } + let endingText = "The End"; + if (ending.text !== undefined && ending.text !== null) { + if (typeof ending.text !== "string" || !ending.text.trim() || ending.text.trim().length > 60 || /[\x00-\x1f\x7f]/.test(ending.text)) + w.push('"ending.text" must be 1-60 characters of single-line text. Using "The End".'); + else endingText = ending.text.trim(); + } + // Both ending controls are independent; older game.yaml files keep the + // reader's historical default: enabled replay with its English label. + let playAgainShow = true; + if (ending.play_again_show !== undefined && ending.play_again_show !== null) { + if (typeof ending.play_again_show === "boolean") playAgainShow = ending.play_again_show; + else w.push('"ending.play_again_show" must be true or false. Using true.'); + } + let playAgainText = "Play Again"; + if (ending.play_again_text !== undefined && ending.play_again_text !== null) { + if (typeof ending.play_again_text !== "string" || !ending.play_again_text.trim() + || ending.play_again_text.trim().length > 60 || /[\x00-\x1f\x7f]/.test(ending.play_again_text)) + w.push('"ending.play_again_text" must be 1-60 characters of single-line text. Using "Play Again".'); + else playAgainText = ending.play_again_text.trim(); + } + const themeModeOption = pickChoice(raw, "", "theme_mode", ["single", "dual"], "", w); + // Old game.yaml files only had controls.theme_toggle; migrate them in memory. + const themeMode = themeModeOption || (controls.theme_toggle === true ? "dual" : "single"); + const configuredThemes = readGroup(raw, "themes", w); + + // Font: system, serif, mono, or the name of a font the reader has installed + let family = "system"; + const rawFamily = font.family; + if( rawFamily !== undefined && rawFamily !== null ) { + const key = String(rawFamily).toLowerCase().trim(); + if( Object.prototype.hasOwnProperty.call(FONT_STACKS, key) ) { + family = key; + } else if( /^[\p{L}\p{N} .\-']{1,60}$/u.test(String(rawFamily).trim()) ) { + family = String(rawFamily).trim().replace(/'/g, ""); + } else { + w.push(`"font.family" is not valid (got "${rawFamily}"). Using the system font.`); + } + } + + // Column width: a number of rem, or "full" for the whole page + let width = 42; + if( layout.width !== undefined && layout.width !== null ) { + if( String(layout.width).toLowerCase().trim() === "full" ) { + width = "full"; + } else if( typeof layout.width === "number" && isFinite(layout.width) && layout.width >= 20 && layout.width <= 100 ) { + width = layout.width; + } else { + w.push(`"layout.width" must be a number between 20 and 100, or "full" (got "${layout.width}"). Using 42.`); + } + } + + let show = true; + if( controls.show !== undefined && controls.show !== null ) { + if( typeof controls.show === "boolean" ) show = controls.show; + else w.push(`"controls.show" must be true or false (got "${controls.show}"). Using true.`); + } + // Lets the player switch between the light and the dark look (a button in the story menu) + let themeToggle = false; + if( controls.theme_toggle !== undefined && controls.theme_toggle !== null ) { + if( typeof controls.theme_toggle === "boolean" ) themeToggle = controls.theme_toggle; + else w.push(`"controls.theme_toggle" must be true or false (got "${controls.theme_toggle}"). Using false.`); + } + // The story frame has a single format: vertical (9:16). Retired story_format + // and custom_aspect_ratio keys are ignored. + let storyTitle = ""; + if( titles.story !== undefined && titles.story !== null ) { + if( typeof titles.story === "string" ) storyTitle = normalizeTitleText(titles.story); + else w.push(`"titles.story" must be text (got "${titles.story}"). Using no story title.`); + } + let author = ""; + if( titles.author !== undefined && titles.author !== null ) { + if( typeof titles.author === "string" ) author = normalizeTitleText(titles.author); + else w.push(`"titles.author" must be text (got "${titles.author}"). Using no author.`); + } + + // The cover is a picture inside the project folder, written with "/" (image/cover.jpg) + let cover = ""; + if( titles.cover !== undefined && titles.cover !== null ) { + const reference = typeof titles.cover === "string" ? coverReference(titles.cover) : null; + if( reference ) cover = reference; + else w.push(`"titles.cover" must be the path of an image inside the project folder, such as "image/cover.jpg" (${COVER_EXTENSIONS.join(", ")}). Using no cover.`); + } + + const DEFAULT_STORY_TITLE_PX = 56; + let storySizePx = DEFAULT_STORY_TITLE_PX; + if( Object.prototype.hasOwnProperty.call(titles, "story_size_px") ) { + storySizePx = pickNumber(titles, "titles", "story_size_px", 16, 100, DEFAULT_STORY_TITLE_PX, w); + } else if( Object.prototype.hasOwnProperty.call(titles, "story_size") ) { + const oldPercent = pickNumber(titles, "titles", "story_size", 40, 160, 100, w); + storySizePx = Math.round(DEFAULT_STORY_TITLE_PX * oldPercent / 100); + } + + const defaultFontSize = pickNumber(font, "font", "size", 10, 40, 18, w); + const primary = { colors: { + background: pickColor(colors, "background", base.background, w), + text: pickColor(colors, "text", base.text, w), + choice: pickColor(colors, "choice", base.choice, w), + choiceHover: pickColor(colors, "choice_hover", null, w) + }, fontFamily: family, fontSize: defaultFontSize }; + const variants = { + light: theme === "light" ? copyLook(primary) : presetLook("light", themeFamily), + dark: theme === "dark" ? copyLook(primary) : presetLook("dark", themeFamily) + }; + if( !themeModeOption && !themeFamily ) { + variants[theme === "light" ? "dark" : "light"].fontFamily = family; + } + for( const side of ["light", "dark"] ) { + variants[side].fontSize = defaultFontSize; + } + if( themeMode === "dual" ) { + for( const side of ["light", "dark"] ) { + variants[side] = readThemeLook(configuredThemes[side], side, variants[side], w); + } + } + const currentLook = themeMode === "dual" ? variants[theme] : primary; + + return { + model: { + theme: theme, + themeMode: themeMode, + presentation: presentation, + ending: { show: endingShow, text: endingText, playAgainShow, playAgainText }, + themes: variants, + themeFamily: themeFamily, + colors: Object.assign({}, currentLook.colors), + font: { family: currentLook.fontFamily, size: currentLook.fontSize }, + layout: { + lineHeight: pickNumber(layout, "layout", "line_height", 1, 3, 1.7, w), + width: width, + align: pickChoice(layout, "layout", "align", TEXT_ALIGNS, "left", w), + choiceAlign: pickChoice(layout, "layout", "choice_align", CHOICE_ALIGNS, "left", w) + }, + controls: { show: show, themeToggle: themeMode === "dual" || (themeModeOption === "" && themeToggle) }, + legacyThemeToggle: !themeModeOption && themeToggle, + audio: { + volume: Math.round(pickNumber(audio, "audio", "volume", 0, 100, 50, w)), + master: Math.round(pickNumber(audio, "audio", "master", 0, 100, 100, w)), + music: Math.round(pickNumber(audio, "audio", "music", 0, 100, 100, w)), + sfx: Math.round(pickNumber(audio, "audio", "sfx", 0, 100, 100, w)) + }, + titles: { + story: storyTitle, + author: author, + cover: cover, + storySize: storySizePx, + sceneStyle: pickTitleStyle(titles.scene_style, "titles.scene_style", w, 33.3) + } + }, + warnings: w + }; +} + +function fontStack(family) { + return Object.prototype.hasOwnProperty.call(FONT_STACKS, family) + ? FONT_STACKS[family] + : `"${family}", ${FONT_STACKS.system}`; +} + +function presetById(id) { + return PRESETS.find(p => p.id === id) || null; +} + +// The look the player's switch leads to: the other preset of the writer's family. Colors the +// writer customized apply to the current look only; the other one uses the family's own values. +// Without a family it is the plain palette of the other theme, in the same font. +function alternateLook(model) { + const other = model.theme === "dark" ? "light" : "dark"; + if( model.themeMode === "dual" && model.themes && model.themes[other] ) { + const look = model.themes[other]; + return { theme: other, colors: Object.assign({}, look.colors), fontFamily: look.fontFamily, fontSize: look.fontSize }; + } + const family = model.themeFamily ? FAMILIES[model.themeFamily] : null; + const partner = family ? presetById(family[other]) : null; + if( partner ) { + return { + theme: partner.theme, + colors: Object.assign({}, partner.colors, { choiceHover: null }), + fontFamily: partner.font + }; + } + return { + theme: other, + colors: Object.assign({}, PALETTES[other], { choiceHover: null }), + fontFamily: model.font.family + }; +} + +// A model, ready to be shown: the CSS values the preview and the export use +function modelToStyle(model) { + const palette = model.theme === "dark" ? "dark" : "light"; + const c = model.colors; + // Automatic hover keeps the Choices color; the page only adds a soft tint of it and a side bar + const withHover = colors => ({ + background: colors.background, + text: colors.text, + choice: colors.choice, + choiceHover: colors.choiceHover || colors.choice + }); + + const stack = fontStack(model.font.family); + + // The switch lives in the story menu, so it exists only while the buttons are shown + const themeToggle = model.themeMode === "dual" && + (!model.legacyThemeToggle || !!(model.controls.show && model.controls.themeToggle)); + let alternate = null; + if( themeToggle ) { + const look = alternateLook(model); + alternate = { + theme: look.theme, + colors: withHover(look.colors), + font: { family: fontStack(look.fontFamily), size: look.fontSize || model.font.size } + }; + } + + return { + theme: model.theme, + presentation: model.presentation || "traditional", + ending: { show: model.ending.show, text: model.ending.text, playAgainShow: model.ending.playAgainShow, playAgainText: model.ending.playAgainText }, + alternate: alternate, + palette: palette, + colors: withHover(c), + font: { family: stack, size: model.font.size }, + layout: { + lineHeight: model.layout.lineHeight, + width: model.layout.width === "full" ? "100%" : model.layout.width + "rem", + align: model.layout.align, + choiceAlign: model.layout.choiceAlign + }, + controls: { show: model.controls.show, themeToggle: themeToggle }, + audio: { + volume: model.audio.volume, + master: model.audio.master, + music: model.audio.music, + sfx: model.audio.sfx + }, + titles: { + story: model.titles.story, + author: model.titles.author, + cover: model.titles.cover, + storySize: model.titles.storySize, + sceneStyle: Object.assign({}, model.titles.sceneStyle) + } + }; +} + +function resolve(raw) { + const r = resolveModel(raw); + return { style: modelToStyle(r.model), warnings: r.warnings }; +} + +// text: the contents of a game.yaml +function parseText(text) { + let raw; + try { + raw = jsyaml.load(quoteBareHexColors(text)); + } catch(e) { + const line = e.mark ? ` (line ${e.mark.line + 1})` : ""; + const r = resolveModel({}); + r.warnings.push(`${FILE_NAME} could not be read${line}: ${e.reason || e.message}. Using the default style.`); + return { style: modelToStyle(r.model), model: r.model, warnings: r.warnings }; + } + const r = resolveModel(raw); + return { style: modelToStyle(r.model), model: r.model, warnings: r.warnings }; +} + +// The style file is "game.yaml" unless the writer renamed it. A rename is remembered in a tiny +// file next to the story (POINTER_FILE) that holds the new file name. Without it, a lone YAML +// file in the story folder that clearly is a style file (it has theme, colors, font, layout...) +// is used, so a project whose pointer was lost on the way still finds its style. +const POINTER_FILE = ".inky-style"; + +function isStyleFileName(name) { + return typeof name === "string" && /^[^\\/:*?"<>|\x00-\x1f]+\.ya?ml$/i.test(name) && name !== "." && name !== ".."; +} + +function isRegularFile(absPath) { + try { return fs.statSync(absPath).isFile(); } + catch(e) { return false; } +} + +function looksLikeStyleFile(absPath) { + try { + const raw = jsyaml.load(quoteBareHexColors(fs.readFileSync(absPath, "utf8"))); + return isObject(raw) && Object.keys(raw).some(key => KNOWN[""].indexOf(key) !== -1); + } catch(e) { + return false; + } +} + +function entryFileNameFor(dir) { + try { + const remembered = fs.readFileSync(path.join(dir, POINTER_FILE), "utf8").trim(); + if( isStyleFileName(remembered) && isRegularFile(path.join(dir, remembered)) ) return remembered; + } catch(e) { /* no pointer: use the default name */ } + if( isRegularFile(path.join(dir, FILE_NAME)) ) return FILE_NAME; + try { + const candidates = fs.readdirSync(dir) + .filter(name => isStyleFileName(name) && isRegularFile(path.join(dir, name)) && looksLikeStyleFile(path.join(dir, name))); + if( candidates.length === 1 ) return candidates[0]; + } catch(e) { /* unreadable folder: use the default name */ } + return FILE_NAME; +} + +// Remember which file is the style file. The default name needs no pointer. +function setEntryFileName(dir, name) { + const pointerPath = path.join(dir, POINTER_FILE); + if( name === FILE_NAME ) { + try { fs.unlinkSync(pointerPath); } catch(e) { if( e.code !== "ENOENT" ) throw e; } + return; + } + if( !isStyleFileName(name) ) throw new Error("Not a valid style file name: " + name); + fs.writeFileSync(pointerPath, name + "\n", "utf8"); +} + +// The pointer file's raw content (or null), to put it back if a rename has to be undone. +function readEntryPointer(dir) { + try { return fs.readFileSync(path.join(dir, POINTER_FILE), "utf8"); } + catch(e) { return null; } +} + +function restoreEntryPointer(dir, content) { + const pointerPath = path.join(dir, POINTER_FILE); + if( content === null ) { try { fs.unlinkSync(pointerPath); } catch(e) { /* already gone */ } } + else fs.writeFileSync(pointerPath, content, "utf8"); +} + +function entryFilePathFor(project) { + const dir = project && project.mainInk && project.mainInk.projectDir; + return dir ? path.join(dir, entryFileNameFor(dir)) : null; +} + +function normalizedRelativePath(value) { + return String(value || "").replace(/\\/g, "/"); +} + +function projectDocument(project, absPath) { + if( !project || !absPath || !project.mainInk || !project.mainInk.projectDir ) return null; + const rel = normalizedRelativePath(path.relative(project.mainInk.projectDir, absPath)); + return (project.files || []).find(f => normalizedRelativePath(f.relativePath()) === rel) || null; +} + +function readProjectText(project, absPath) { + const document = projectDocument(project, absPath); + if( document && !document.isLoading ) return { text: document.getValue(), document: document, exists: true }; + if( document && document.isLoading ) { + try { return { text: fs.readFileSync(absPath, "utf8"), document: document, exists: true }; } + catch(e) { /* continue with the regular file read fallback */ } + } + try { + return { text: fs.readFileSync(absPath, "utf8"), document: null, exists: true }; + } catch(e) { + return { text: "", document: null, exists: false, error: e }; + } +} + +function pathStaysInsideRoot(rootPath, targetPath) { + const root = path.resolve(rootPath); + const target = path.resolve(targetPath); + const relative = path.relative(root, target); + if( !relative || relative === "." || relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative) ) return false; + + // Lexical containment alone is insufficient when a project contains a + // symlink to another directory. Resolve the nearest existing path component + // so a pointer cannot escape the story folder through a symlink. + try { + const realRoot = fs.realpathSync(root); + let existing = target; + // lstat is intentional: existsSync follows symlinks and reports false + // for a dangling symlink, which could otherwise be used to escape the + // root when a new subfolder is created through it. + while( true ) { + try { + fs.lstatSync(existing); + break; + } catch(e) { + if( e.code !== "ENOENT" && e.code !== "ENOTDIR" ) return false; + const parent = path.dirname(existing); + if( parent === existing ) return false; + existing = parent; + } + } + const realExisting = fs.realpathSync(existing); + const realRelative = path.relative(realRoot, realExisting); + return realRelative === "" || (realRelative !== ".." && !realRelative.startsWith(".." + path.sep) && !path.isAbsolute(realRelative)); + } catch(e) { + return false; + } +} + +// The project's only visual configuration is game.yaml at the story root. +function activeStylePathFor(project) { + return entryFilePathFor(project); +} + +function filePathFor(project) { + return entryFilePathFor(project); +} + +function isActiveStylePath(project, absPath) { + const entry = entryFilePathFor(project); + return !!(entry && absPath && path.resolve(entry) === path.resolve(absPath)); +} + +function loadFromProject(project) { + const entryPath = entryFilePathFor(project); + if( !entryPath ) { + // A brand-new project has no folder until its first save. Its virtual + // game.yaml document still drives the preview and the Visual form. + const virtualDocument = project && Array.isArray(project.files) + ? project.files.find(file => String(file.relativePath && file.relativePath() || "").replace(/\\/g, "/").toLowerCase() === FILE_NAME.toLowerCase() && file.isYamlFile) + : null; + if( virtualDocument && !virtualDocument.isLoading ) { + const parsed = parseText(virtualDocument.getValue()); + return { style: parsed.style, warnings: parsed.warnings, path: null, entryPath: null, exists: true, document: virtualDocument }; + } + const r = resolve({}); + return { style: r.style, warnings: [], path: null, entryPath: null, exists: false }; + } + + if( !pathStaysInsideRoot(project.mainInk.projectDir, entryPath) ) { + const r = resolve({}); + return { style: r.style, warnings: [`${FILE_NAME} must resolve inside the story folder. Using the default style.`], path: entryPath, entryPath: entryPath, exists: false }; + } + const entry = readProjectText(project, entryPath); + if( !entry.exists ) { + const r = resolve({}); + return { style: r.style, warnings: [], path: entryPath, entryPath: entryPath, exists: false }; + } + + const parsed = parseText(entry.text); + return { style: parsed.style, warnings: parsed.warnings, path: entryPath, entryPath: entryPath, exists: true }; +} + +// ---- The reader's surrounding surface ----------------------------------- + +// The outside of the reading frame belongs to the active theme, not to the +// editor's theme or a fixed website color. Keep these values derived rather +// than adding another setting to game.yaml. CSS color-mix supports the same +// custom CSS colors the writer can enter in the Visual/Advanced editor. +// RGB brightness of hex colors. Color names/functions are still accepted by the +// style editor; those use the conservative branch below, without guessing. +function hexBrightness(color) { + const value = String(color || "").trim(); + const short = /^#([0-9a-f]{3,4})$/i.exec(value); + const long = /^#([0-9a-f]{6})(?:[0-9a-f]{2})?$/i.exec(value); + const channels = short + ? short[1].slice(0, 3).split("").map(c => parseInt(c + c, 16)) + : long + ? [0, 2, 4].map(i => parseInt(long[1].slice(i, i + 2), 16)) + : null; + if( !channels ) return null; + return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722; +} + +function readerAmbience(look) { + const dark = look.theme === "dark"; + const background = look.colors.background; + const text = look.colors.text; + // Near-black players (Terminal, or a custom midnight look) need a *lighter* + // surrounding surface: a black drop shadow on black cannot give separation. + // Other dark frames sit against a deeper, cool outer canvas instead. + const brightness = hexBrightness(background); + const nearBlack = dark && brightness !== null && brightness < 28; + return { + outer: dark + ? nearBlack + ? `color-mix(in srgb, ${background} 22%, #1e2a29)` + : `color-mix(in srgb, ${background} 16%, #0d131a)` + : `color-mix(in srgb, ${background} 87%, ${text})`, + // A real 1px edge makes the player legible even when its shadow and the + // surrounding backdrop are close in value. It is not an extra YAML option. + border: `color-mix(in srgb, ${text} ${dark ? (nearBlack ? 30 : 21) : 20}%, ${background})`, + deep: dark ? "rgba(0, 0, 0, 0.74)" : `color-mix(in srgb, ${text} 26%, transparent)`, + soft: dark ? "rgba(0, 0, 0, 0.42)" : `color-mix(in srgb, ${text} 16%, transparent)`, + halo: `color-mix(in srgb, ${text} ${nearBlack ? 20 : dark ? 12 : 5}%, transparent)` + }; +} + +// ---- The exported web page ---------------------------------------------- + +// CSS appended to the template's style.css. It only sets the variables the +// template already uses, so the template stays plain and readable. +function exportCss(style) { + const colorVars = (look) => { + const c = look.colors; + const ambience = readerAmbience(look); + return [ + ` --bg: ${c.background};`, + ` --text: ${c.text};`, + ` --link: ${c.choice};`, + ` --link-hover: ${c.choiceHover};`, + ` --reader-outer-bg: ${ambience.outer};`, + ` --reader-frame-border: ${ambience.border};`, + ` --reader-shadow-deep: ${ambience.deep};`, + ` --reader-shadow-soft: ${ambience.soft};`, + ` --reader-shadow-halo: ${ambience.halo};` + ]; + }; + const out = [ + "", + "/* ---- Game style, generated from game.yaml ---- */", + ":root {", + ` --font: ${style.font.family};`, + ` --font-size: ${style.font.size}px;`, + ` --line-height: ${style.layout.lineHeight};`, + ` --width: ${style.layout.width};`, + ` --story-align: ${style.layout.align};`, + ` --choice-align: ${style.layout.choiceAlign};` + ]; + out.push(...colorVars({ theme: style.theme, colors: style.colors })); + out.push("}"); + // The look the player's switch leads to; the page sets data-look="alternate" on + if( style.alternate ) out.push( + "", + "/* Alternate look: the player's light/dark switch */", + ':root[data-look="alternate"] {', + ` --font: ${style.alternate.font.family};`, + ` --font-size: ${style.alternate.font.size}px;`, + ...colorVars(style.alternate), + "}" + ); + const sceneStyle = style.titles.sceneStyle; + if( sceneStyle.configured ) { + const declarations = []; + // "Default" keeps the base scene-title serif, matching Preview. Specifying + // only a size must not silently switch the font family in Web Export. + if( sceneStyle.family !== "inherit" ) declarations.push(` font-family: ${Object.prototype.hasOwnProperty.call(FONT_STACKS, sceneStyle.family) + ? FONT_STACKS[sceneStyle.family] + : `"${sceneStyle.family}", ${FONT_STACKS.system}`};`); + declarations.push(` font-size: ${sceneStyle.size}px;`); + if( sceneStyle.color ) declarations.push(` color: ${sceneStyle.color};`); + // "auto" follows the text. + if( sceneStyle.align !== "auto" ) declarations.push(` text-align: ${sceneStyle.align};`); + out.push("", ".container .scene-title {", ...declarations, "}"); + } + if( !style.controls.show && style.controls.themeToggle ) out.push( + "", + "/* In dual mode the theme switch remains accessible even without save/restart controls. */", + "#story-menu #rewind, #story-menu #save, #story-menu #reload { display: none; }" + ); + else if( !style.controls.show ) out.push( + "", + "/* Buttons hidden: a story that has sound keeps only its volume control. */", + "#controls:not(.has-audio) {", " display: none;", "}", + "#controls.has-audio > :not(.audio-control) {", " display: none;", "}", + "#controls.has-audio > .audio-control:after {", " content: none;", "}" + ); + return out.join("\n") + "\n"; +} + +// ---- The preview in the IDE ---------------------------------------------- + +let currentStyle = resolve({}).style; +// The Preview can try the player's light/dark switch: this says which look it shows now +let previewAlternate = false; +let previewProject = null; +let previewStyle = null; + +function current() { + return currentStyle; +} + +// The look on screen: the alternate one while the player's switch is on +function lookOf(style, showAlternate) { + if( showAlternate && style.alternate ) { + return { theme: style.alternate.theme, colors: style.alternate.colors, family: style.alternate.font.family, size: style.alternate.font.size }; + } + return { theme: style.theme, colors: style.colors, family: style.font.family, size: style.font.size }; +} + +function togglePreviewLook() { + const style = previewStyle || currentStyle; + if( !style.alternate ) return; + previewAlternate = !previewAlternate; + applyToPreview(style, previewProject); +} + +function setPreviewTheme(theme) { + const style = previewStyle || currentStyle; + if( !["light", "dark"].includes(theme) ) return; + previewAlternate = !!(style.alternate && style.alternate.theme === theme); + applyToPreview(style, previewProject); +} + +// The preview reads these custom properties (see "Game style" in main.css), +// so the story looks the same as in the exported page, whatever the IDE theme. +function applyToPreview(style, project) { + const player = document.getElementById("player"); + if( !player ) return; + previewProject = project || null; + previewStyle = style; + if( !style.alternate ) previewAlternate = false; + const look = lookOf(style, previewAlternate); + const ambience = readerAmbience(look); + const vars = { + "--reader-outer-bg": ambience.outer, + "--reader-frame-border": ambience.border, + "--reader-shadow-deep": ambience.deep, + "--reader-shadow-soft": ambience.soft, + "--reader-shadow-halo": ambience.halo, + "--game-bg": look.colors.background, + "--game-text": look.colors.text, + "--game-choice": look.colors.choice, + "--game-choice-hover": look.colors.choiceHover, + "--game-font": look.family, + "--game-font-size": look.size + "px", + "--game-line-height": String(style.layout.lineHeight), + "--game-width": style.layout.width, + "--game-story-align": style.layout.align, + "--game-choice-align": style.layout.choiceAlign, + "--game-story-aspect-ratio": "9 / 16", + "--game-story-aspect-ratio-number": String(9 / 16) + }; + Object.keys(vars).forEach(k => { + if( vars[k] ) player.style.setProperty(k, vars[k]); + else player.style.removeProperty(k); + }); + player.classList.add("game-preview-panel"); + player.dataset.presentation = style.presentation || "traditional"; + player.dataset.endingShow = style.ending.show ? "1" : "0"; + player.dataset.endingText = style.ending.text; + player.dataset.endingPlayAgainShow = style.ending.playAgainShow ? "1" : "0"; + player.dataset.endingPlayAgainText = style.ending.playAgainText; + player.querySelectorAll(".story-replay-choice").forEach(replay => { + replay.hidden = !style.ending.playAgainShow; + const button = replay.querySelector("button"); + if (button) button.textContent = style.ending.playAgainText; + const footer = replay.closest(".story-choices"); + if (footer && !footer.querySelector(".choice:not(.story-replay-choice)")) { + footer.hidden = !style.ending.playAgainShow; + const frame = footer.closest(".innerText"); + if (frame) frame.classList.toggle("has-choices", style.ending.playAgainShow); + } + }); + player.querySelectorAll(".story-ending").forEach(marker => { + const EndingMarker = require("./endingMarker.js"); + EndingMarker.update(marker, style.ending.show, style.ending.text); + }); + player.dataset.controlsShow = style.controls.show ? "1" : "0"; + // The switch in the story menu names the theme it leads to + player.dataset.themeToggle = style.controls.themeToggle ? "1" : "0"; + player.dataset.themeTarget = style.alternate + ? (previewAlternate ? style.theme : style.alternate.theme) + : ""; + const globalTags = project && project.mainInk && project.mainInk.symbols + && Array.isArray(project.mainInk.symbols.globalTags) + ? project.mainInk.symbols.globalTags : []; + player.dataset.globalTags = JSON.stringify(globalTags); + // The title screen is built from these (see titleScreen.js); the story itself no longer shows them. + const coverFile = project && project.mainInk ? resolveCoverFile(project.mainInk.projectDir, style.titles.cover) : null; + player.dataset.storyTitle = style.titles.story || ""; + player.dataset.storyTitleSize = String(style.titles.storySize); + player.dataset.storyAuthor = style.titles.author || ""; + player.dataset.storyCover = coverFile ? pathToFileURL(coverFile).href : ""; + TitleScreen.sync(player); + GameChrome.sync(player); + const sceneStyle = style.titles.sceneStyle; + const scenePrefix = "--game-scene-title-"; + const sceneVars = { + family: sceneStyle.family === "inherit" ? "" : (Object.prototype.hasOwnProperty.call(FONT_STACKS, sceneStyle.family) ? FONT_STACKS[sceneStyle.family] : `"${sceneStyle.family}", ${FONT_STACKS.system}`), + size: sceneStyle.size + "px", + color: sceneStyle.color || "", + align: sceneStyle.align === "auto" ? "" : sceneStyle.align + }; + if( !sceneStyle.configured ) { + ["family", "size", "color", "align"].forEach(key => player.style.removeProperty(scenePrefix + key)); + } else { + Object.keys(sceneVars).forEach(key => { + if( sceneVars[key] ) player.style.setProperty(scenePrefix + key, sceneVars[key]); + else player.style.removeProperty(scenePrefix + key); + }); + } +} + +let reloadListener = null; + +function reload(project) { + const r = loadFromProject(project); + currentStyle = r.style; + applyToPreview(r.style, project); + if( reloadListener ) reloadListener(r.style); + if( r.warnings.length > 0 ) + console.warn(`${FILE_NAME}:\n- ` + r.warnings.join("\n- ")); + return r; +} + +// ---- Presets and YAML serialization for the Visual form ----------------- + +// A preset sets the theme, the colors and the font. Size, width and +// alignment stay as the writer had them. The four presets form two families +// (see FAMILIES): the player's light/dark switch moves between the two presets of a family. + + +function defaultModel() { + return resolveModel({}).model; +} + +function applyPreset(model, presetId) { + const preset = PRESETS.find(p => p.id === presetId); + if( !preset ) return model; + return Object.assign({}, model, { + theme: preset.theme, + themeFamily: preset.family, + colors: Object.assign({}, preset.colors, { choiceHover: null }), + font: Object.assign({}, model.font, { family: preset.font }) + }); +} + +// Change one side without touching the other. The root look mirrors the initial theme +// for all existing readers of the model, while `themes` remains the canonical pair. +function setThemeLook(model, side, look) { + if( !["light", "dark"].includes(side) ) return model; + model.themes[side] = copyLook(look); + if( model.theme === side ) { + model.colors = Object.assign({}, look.colors); + model.font.family = look.fontFamily; + model.font.size = look.fontSize; + } + return model; +} +function applyThemePreset(model, side, presetId) { + const preset = PRESETS.find(p => p.id === presetId && p.theme === side); + if( !preset ) return model; + return setThemeLook(model, side, { colors: Object.assign({}, preset.colors, { choiceHover: null }), fontFamily: preset.font, fontSize: model.themes[side].fontSize }); +} +function setThemeMode(model, mode) { + if( mode !== "single" && mode !== "dual" ) return model; + if( mode === "dual" && model.themeMode !== "dual" ) { + // Capture existing customized colors/font before enabling the other theme. + setThemeLook(model, model.theme, { colors: model.colors, fontFamily: model.font.family, fontSize: model.font.size }); + } + if( mode === "single" && model.themeMode === "dual" ) { + // Keep the currently configured initial look as the single theme. + const look = model.themes[model.theme]; + model.colors = Object.assign({}, look.colors); + model.font.family = look.fontFamily; + model.font.size = look.fontSize; + } + model.themeMode = mode; + model.controls.themeToggle = mode === "dual"; + model.legacyThemeToggle = false; + return model; +} + +// Serialize all visual settings without explanatory comments. +function toYaml(model) { + const q = v => JSON.stringify(String(v)); + const dual = model.themeMode === "dual"; + const c = model.colors; + const lines = [ + `theme: ${model.theme}`, + ...(model.presentation === "screen" ? ["presentation: screen"] : []), + "ending:", + ` show: ${model.ending.show}`, + ` text: ${q(model.ending.text)}`, + ` play_again_show: ${model.ending.playAgainShow !== false}`, + ` play_again_text: ${q(model.ending.playAgainText || "Play Again")}`, + ...(dual ? ["theme_mode: dual"] : []), + ...(!dual && model.themeFamily ? [`theme_family: ${model.themeFamily}`] : []), + "" + ]; + if( dual ) { + lines.push("themes:"); + for( const side of ["light", "dark"] ) { + const look = model.themes[side]; + lines.push(` ${side}:`, + ` font: ${Object.prototype.hasOwnProperty.call(FONT_STACKS, look.fontFamily) ? look.fontFamily : q(look.fontFamily)}`, + ` size: ${look.fontSize}`, + " colors:", + ` background: ${q(look.colors.background)}`, + ` text: ${q(look.colors.text)}`, + ` choice: ${q(look.colors.choice)}`, + ` choice_hover: ${look.colors.choiceHover ? q(look.colors.choiceHover) : "null"}`); + } + } else { + lines.push("colors:", + ` background: ${q(c.background)}`, + ` text: ${q(c.text)}`, + ` choice: ${q(c.choice)}`, + ` choice_hover: ${c.choiceHover ? q(c.choiceHover) : "null"}`); + } + lines.push("", + ...(!dual ? ["font:", + ` family: ${Object.prototype.hasOwnProperty.call(FONT_STACKS, model.font.family) ? model.font.family : q(model.font.family)}`, + ` size: ${model.font.size}`, ""] : []), + "layout:", + ` line_height: ${model.layout.lineHeight}`, + ` width: ${model.layout.width === "full" ? "full" : model.layout.width}`, + ` align: ${model.layout.align}`, + ` choice_align: ${model.layout.choiceAlign}`, + "", + "controls:", + ` show: ${model.controls.show}`, + ...(!dual ? [` theme_toggle: ${!!model.controls.themeToggle}`] : []), + "", + "audio:", + ` volume: ${model.audio.volume}`, + ` master: ${model.audio.master}`, + ` music: ${model.audio.music}`, + ` sfx: ${model.audio.sfx}`); + if( model.titles.story || model.titles.author || model.titles.cover || model.titles.storySize !== 56 || model.titles.sceneStyle.configured ) { + lines.push("", "titles:"); + const storyTitle = normalizeTitleText(model.titles.story); + const storyAuthor = normalizeTitleText(model.titles.author); + if( storyTitle ) lines.push(` story: ${q(storyTitle)}`); + if( storyAuthor ) lines.push(` author: ${q(storyAuthor)}`); + if( model.titles.cover ) lines.push(` cover: ${q(model.titles.cover)}`); + if( model.titles.storySize !== 56 ) lines.push(` story_size_px: ${model.titles.storySize}`); + const sceneStyle = model.titles.sceneStyle; + if( sceneStyle.configured ) { + lines.push(" scene_style:"); + lines.push(` family: ${sceneStyle.family === "inherit" || Object.prototype.hasOwnProperty.call(FONT_STACKS, sceneStyle.family) ? sceneStyle.family : q(sceneStyle.family)}`); + lines.push(` size: ${sceneStyle.size}`); + lines.push(` color: ${sceneStyle.color ? q(sceneStyle.color) : "null"}`); + lines.push(` align: ${sceneStyle.align}`); + } + } + lines.push(""); + return lines.join("\n"); +} + +function InkProjectNotify(project) { + // The project event is optional so the style module remains unit-testable. + try { + const Project = require("./inkProject.js").InkProject; + if( Project.events && Project.events.gameStyleChanged ) Project.events.gameStyleChanged(); + return true; + } catch(e) { return false; } +} + +// ---- Creating the file --------------------------------------------------- + +function templateYaml() { + return [ + "theme: light", + "presentation: traditional", + "ending:", + " show: true", + ' text: "The End"', + " play_again_show: true", + ' play_again_text: "Play Again"', + "", + "colors:", + ' background: "#ffffff"', + ' text: "#000000"', + ' choice: "#0066CC"', + " choice_hover: null", + "", + "font:", + " family: system", + " size: 18", + "", + "layout:", + " line_height: 1.7", + " width: 42", + " align: left", + " choice_align: left", + "", + "controls:", + " show: true", + " theme_toggle: false", + "", + "audio:", + " volume: 50", + " master: 100", + " music: 100", + " sfx: 100", + "" + ].join("\n"); +} + +exports.GameStyle = { + FILE_NAME: FILE_NAME, + parseText: parseText, + normalizeTitleText: normalizeTitleText, + isColor: isColor, + resolve: resolve, + resolveModel: resolveModel, + modelToStyle: modelToStyle, + defaultModel: defaultModel, + applyPreset: applyPreset, + applyThemePreset: applyThemePreset, + setThemeLook: setThemeLook, + setThemeMode: setThemeMode, + normalizeCoverReference: coverReference, + resolveCoverFile: resolveCoverFile, + coverReferenceForFile: coverReferenceForFile, + toYaml: toYaml, + PRESETS: PRESETS, + FAMILIES: FAMILIES, + alternateLook: alternateLook, + togglePreviewLook: togglePreviewLook, + setPreviewTheme: setPreviewTheme, + FONT_STACKS: FONT_STACKS, + PALETTES: PALETTES, + loadFromProject: loadFromProject, + POINTER_FILE: POINTER_FILE, + entryFilePathFor: entryFilePathFor, + entryFileNameFor: entryFileNameFor, + setEntryFileName: setEntryFileName, + readEntryPointer: readEntryPointer, + restoreEntryPointer: restoreEntryPointer, + isStyleFileName: isStyleFileName, + filePathFor: filePathFor, + activeStylePathFor: activeStylePathFor, + isActiveStylePath: isActiveStylePath, + pathStaysInsideRoot: pathStaysInsideRoot, + exportCss: exportCss, + readerAmbience: readerAmbience, + templateYaml: templateYaml, + current: current, + applyToPreview: applyToPreview, + reload: reload, + setReloadListener: fn => { reloadListener = fn; } +}; diff --git a/app/renderer/goto.js b/app/renderer/goto.js index 624d096d..a6ee48a6 100644 --- a/app/renderer/goto.js +++ b/app/renderer/goto.js @@ -2,6 +2,7 @@ const path = require("path"); const electron = require("electron"); const ipc = electron.ipcRenderer; const _ = require("lodash"); +const crypto = require("crypto"); const {filter, wrap, score} = require("fuzzaldrin-plus"); const $ = window.jQuery = require('./jquery-2.2.3.min.js'); @@ -10,6 +11,8 @@ const InkProject = require("./inkProject.js").InkProject; const EditorView = require("./editorView.js").EditorView; const i18n = require("./i18n.js"); +const { SafeHtml } = require("./safeHtml.js"); +const escapeHtml = SafeHtml.escapeHtml; var $goto = null; var $gotoContainer = null; @@ -254,10 +257,13 @@ function addResult(result, searchStr) { var resultContent = result.name || result.line; - var wrappedResult = wrap(resultContent, searchStr, { wrap: { - tagOpen: "", - tagClose: "" - }}); + var marker = "__INKY_GOTO_" + crypto.randomBytes(16).toString("hex") + "__"; + while( resultContent.indexOf(marker) !== -1 ) marker = "__INKY_GOTO_" + crypto.randomBytes(16).toString("hex") + "__"; + var wrappedResult = escapeHtml(wrap(resultContent, searchStr, { wrap: { + tagOpen: marker + "OPEN__", + tagClose: marker + "CLOSE__" + }})).replace(new RegExp(marker + "OPEN__", "g"), "") + .replace(new RegExp(marker + "CLOSE__", "g"), ""); var type = resultType(result); @@ -268,16 +274,16 @@ function addResult(result, searchStr) var file = result.file; var dirName = path.dirname(file.relativePath()); if( dirName != "." ) - dirStr = `${dirName}/`; + dirStr = `${escapeHtml(dirName)}/`; $result = $(`
  • 📄 ${dirStr}${wrappedResult}
  • `); } else if( type == "gotoLine" ) { - $result = $(`
  • ➡︎ ${i18n._("Go to line")} ${result.line+1}

    ${result.lineContent}

  • `); + $result = $(`
  • ➡︎ ${i18n._("Go to line")} ${result.line+1}

    ${escapeHtml(result.lineContent)}

  • `); } else if( type == "runtimePath" ) { - $result = $(`
  • 🔎 ${result.lineContent}

    ${result.file.filename()} - ${i18n._("line")} ${result.line+1} (${i18n._("looked up internal runtime path")} ${result.runtimePath})

  • `); + $result = $(`
  • 🔎 ${escapeHtml(result.lineContent)}

    ${escapeHtml(result.file.filename())} - ${i18n._("line")} ${result.line+1} (${i18n._("looked up internal runtime path")} ${escapeHtml(result.runtimePath)})

  • `); } else if( type == "symbol" ) { @@ -288,17 +294,17 @@ function addResult(result, searchStr) ancestor = ancestor.parent; } if( ancestorStr ) - ancestorStr = `${ancestorStr}`; + ancestorStr = `${escapeHtml(ancestorStr)}`; var filePath = result.inkFile.relativePath(); var lineNo = result.row+1; - $result = $(`
  • ✎ ${ancestorStr}${wrappedResult}

    ${filePath} - ${i18n._("line")} ${lineNo}

  • `); + $result = $(`
  • ✎ ${ancestorStr}${wrappedResult}

    ${escapeHtml(filePath)} - ${i18n._("line")} ${lineNo}

  • `); } else if( type == "content" ) { var filePath = result.file.relativePath(); var lineNo = result.row+1; - $result = $(`
  • ${wrappedResult}

    ${filePath} - ${i18n._("line")} ${lineNo}

  • `); + $result = $(`
  • ${wrappedResult}

    ${escapeHtml(filePath)} - ${i18n._("line")} ${lineNo}

  • `); } $result.data("result", result); diff --git a/app/renderer/imageTags.js b/app/renderer/imageTags.js new file mode 100644 index 00000000..815b2c2c --- /dev/null +++ b/app/renderer/imageTags.js @@ -0,0 +1,223 @@ +"use strict"; + +// Story pictures: +// # image: hero.png looks in the project's "image" folder +// # bg: forest.jpg looks in "image/background" +// A path in the tag is resolved from the project folder. A bare filename is searched in +// its tag's conventional folder (and at the project root for compatibility). The old "images" +// folder is a read-only fallback for existing projects; new imports use "image". + +const fs = require("fs"); +const path = require("path"); +const { + isImageFileName, + IMAGE_FOLDER, + IMAGE_BACKGROUND_FOLDER, + LEGACY_IMAGE_SEARCH_FOLDERS +} = require("./imageTypes.js"); + +function parseImageTag(tag) { + const match = /^\s*#?\s*image\s*:\s*(.*?)\s*$/i.exec(String(tag == null ? "" : tag)); + return match ? { kind: "image", reference: match[1].trim() } : null; +} + +function parseBackgroundTag(tag) { + const match = /^\s*#?\s*bg\s*:\s*(.*?)\s*$/i.exec(String(tag == null ? "" : tag)); + return match ? { kind: "background", reference: match[1].trim() } : null; +} + +function parseStoryPictureTag(tag) { + return parseImageTag(tag) || parseBackgroundTag(tag); +} + +function imageTagKey(kind, reference) { + return String(kind || "image") + "\u0000" + String(reference || ""); +} + +function collectImagesFromStory(storyJson) { + const references = new Map(); + const visit = node => { + if( Array.isArray(node) ) { + let tagText = null; + node.forEach(item => { + if( item === "#" ) { tagText = ""; return; } + if( item === "/#" ) { if( tagText !== null ) check(tagText); tagText = null; return; } + if( tagText !== null && typeof item === "string" && item.charAt(0) === "^" ) { tagText += item.slice(1); return; } + visit(item); + }); + return; + } + if( node && typeof node === "object" ) { + if( typeof node["#"] === "string" ) check(node["#"]); + Object.keys(node).forEach(key => visit(node[key])); + } + }; + const check = text => { + const tag = parseStoryPictureTag(text); + if( tag && tag.reference ) references.set(imageTagKey(tag.kind, tag.reference), tag); + }; + visit(storyJson); + const found = Array.from(references.values()).sort((a, b) => + a.kind.localeCompare(b.kind) || a.reference.localeCompare(b.reference)); + return { + files: Array.from(new Set(found.map(item => item.reference))).sort(), + references: found + }; +} + +function insideProject(root, candidate) { + const relative = path.relative(root, candidate); + return !!relative && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative); +} + +function existingFile(candidate) { + try { return fs.statSync(candidate).isFile(); } + catch(error) { return false; } +} + +function resolveImageReference(projectDir, reference, kind) { + const value = String(reference == null ? "" : reference).trim().replace(/\\/g, "/"); + if( !value ) return { kind: "invalid" }; + if( /^https?:\/\//i.test(value) ) return { kind: "remote" }; + if( value.startsWith("//") ) return { kind: "invalid" }; + if( /^[a-z][a-z0-9+.-]*:/i.test(value) || path.posix.isAbsolute(value) ) return { kind: "invalid" }; + if( !isImageFileName(value) ) return { kind: "invalid" }; + const hasExplicitPrefix = /^\.\//.test(value) || /^(?:image|images)\//i.test(value); + const relative = path.posix.normalize(value); + if( relative === "." || relative === ".." || relative.startsWith("../") ) return { kind: "invalid" }; + if( !projectDir ) return { kind: "missing" }; + + const root = path.resolve(projectDir); + // Resolve the real project directory before trusting any image path. A symlink + // inside image/ must not make an outside file available to Preview or export. + let realRoot; + try { realRoot = fs.realpathSync(root); } + catch(error) { return { kind: "missing" }; } + const defaultFolder = kind === "background" ? IMAGE_BACKGROUND_FOLDER : IMAGE_FOLDER; + // A bare filename is resolved in its tag's conventional folder. Explicit image/ or ./ paths + // are rooted at the project; other nested paths retain the older lookup behavior for existing + // projects. The old plural "images" folder remains a read-only fallback. + const candidates = [relative]; + if( !hasExplicitPrefix ) { + candidates.push(defaultFolder + "/" + relative); + if( kind === "background" ) candidates.push("images/backgrounds/" + relative); + candidates.push("images/" + relative); + } + const matches = []; + candidates.forEach(candidate => { + const source = path.resolve(root, candidate.split("/").join(path.sep)); + if( !insideProject(root, source) || !existingFile(source) ) return; + let realSource; + try { realSource = fs.realpathSync(source); } + catch(error) { return; } + if( !insideProject(realRoot, realSource) ) return; + if( !matches.some(match => match.source === source) ) matches.push({ source, relative: candidate }); + }); + + if( matches.length > 1 ) { + return { kind: "ambiguous", candidates: matches.map(match => match.relative) }; + } + if( matches.length === 0 ) return { kind: "missing" }; + const match = matches[0]; + return { + kind: "ok", + source: match.source, + relative: match.relative, + defaulted: match.relative !== relative + }; +} + +function walkImageFiles(projectDir, relativeRoot) { + const absoluteRoot = path.join(projectDir, relativeRoot); + const results = []; + const scan = (absoluteDir, relativeDir) => { + let entries; + try { entries = fs.readdirSync(absoluteDir, { withFileTypes: true }); } + catch(error) { return; } + entries.forEach(entry => { + if( entry.name === ".git" || entry.name === "node_modules" || entry.name === ".DS_Store" || entry.isSymbolicLink() ) return; + const relativePath = relativeDir ? relativeDir + "/" + entry.name : entry.name; + if( entry.isDirectory() ) scan(path.join(absoluteDir, entry.name), relativePath); + else if( entry.isFile() && isImageFileName(entry.name) ) results.push(relativePath); + }); + }; + scan(absoluteRoot, ""); + return results; +} + +// Autocomplete values are filename-only for files directly inside the conventional folder. +// Files in nested/custom folders keep an explicit project-relative path, so the resolver never +// has to guess which subfolder the writer intended. +function listUsableImageFiles(projectDir, kind) { + if( !projectDir ) return []; + const names = new Set(); + if( kind === "background" ) { + walkImageFiles(projectDir, IMAGE_BACKGROUND_FOLDER).forEach(relative => { + names.add(relative.indexOf("/") === -1 ? relative : IMAGE_BACKGROUND_FOLDER + "/" + relative); + }); + // Old projects may still keep backgrounds in images/backgrounds. + walkImageFiles(projectDir, "images/backgrounds").forEach(relative => names.add("images/backgrounds/" + relative)); + } else { + walkImageFiles(projectDir, IMAGE_FOLDER).forEach(relative => { + names.add(relative.indexOf("/") === -1 ? relative : IMAGE_FOLDER + "/" + relative); + }); + // Keep legacy assets discoverable without using the old folder for new imports. + walkImageFiles(projectDir, "images").forEach(relative => names.add("images/" + relative)); + } + return Array.from(names).sort((a, b) => a.localeCompare(b)); +} + +function rewriteImageReferences(story, replacements) { + const rewriteTag = text => { + const parsed = parseStoryPictureTag(text); + if( !parsed ) return text; + const typedKey = imageTagKey(parsed.kind, parsed.reference); + const replacement = replacements.has(typedKey) ? replacements.get(typedKey) : replacements.get(parsed.reference); + // An empty replacement deliberately turns a failed reference into an inert + // tag (image: or bg:). It must not create a broken image in the browser. + if( replacement === undefined || replacement === null ) return text; + return String(text).replace(/^((?:\s*#?\s*(?:image|bg)\s*:\s*))(.*?)(\s*)$/i, + (_, prefix, value, suffix) => prefix + replacement + suffix); + }; + const visit = node => { + if( Array.isArray(node) ) { + let tagTextIndexes = null; + for( let index = 0; index < node.length; index++ ) { + const item = node[index]; + if( item === "#" ) { tagTextIndexes = []; continue; } + if( item === "/#" ) { + if( tagTextIndexes && tagTextIndexes.length ) { + const tagText = tagTextIndexes.map(tagIndex => node[tagIndex].slice(1)).join(""); + const rewritten = rewriteTag(tagText); + if( rewritten !== tagText ) { + tagTextIndexes.forEach((tagIndex, part) => { node[tagIndex] = part === 0 ? "^" + rewritten : "^"; }); + } + } + tagTextIndexes = null; + continue; + } + if( tagTextIndexes && typeof item === "string" && item.charAt(0) === "^" ) { + tagTextIndexes.push(index); + continue; + } + visit(item); + } + return; + } + if( node && typeof node === "object" ) { + if( typeof node["#"] === "string" ) node["#"] = rewriteTag(node["#"]); + Object.keys(node).forEach(key => visit(node[key])); + } + }; + visit(story); +} + +module.exports = { + parseImageTag, + parseBackgroundTag, + collectImagesFromStory, + resolveImageReference, + listUsableImageFiles, + rewriteImageReferences, + imageTagKey +}; diff --git a/app/renderer/imageTypes.js b/app/renderer/imageTypes.js new file mode 100644 index 00000000..42de3c6c --- /dev/null +++ b/app/renderer/imageTypes.js @@ -0,0 +1,66 @@ +"use strict"; + +const path = require("path"); + +// Image files imported into a project live in "image". Backgrounds and character art have +// conventional subfolders, but writers may keep assets elsewhere and reference explicit paths. +const FAVICON_EXTENSIONS = [".ico", ".png", ".svg"]; +const COVER_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg"]; +const IMAGE_EXTENSIONS = Array.from(new Set(FAVICON_EXTENSIONS.concat(COVER_EXTENSIONS))); +const IMAGE_FOLDER = "image"; +const IMAGE_BACKGROUND_FOLDER = "image/background"; +const IMAGE_CHARACTER_FOLDER = "image/character"; +// Read-only fallback for projects created before the canonical singular "image" folder. +const LEGACY_IMAGE_SEARCH_FOLDERS = ["images"]; +// Bigger pictures are imported as they are, but the writer is told about them. +const LARGE_IMAGE_BYTES = 5 * 1024 * 1024; + +const MIME_TYPES = { + ".ico": "image/x-icon", + ".png": "image/png", + ".svg": "image/svg+xml", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".gif": "image/gif" +}; + +function imageExtension(fileName) { + return path.extname(String(fileName || "")).toLowerCase(); +} + +function isImageFileName(fileName) { + return IMAGE_EXTENSIONS.includes(imageExtension(fileName)); +} + +// The file that becomes the project's favicon: any .ico, and a file named favicon.. +// Every other picture is an ordinary image, so it can never replace the favicon. +function isFaviconFileName(fileName) { + const name = String(fileName || "").toLowerCase(); + const extension = imageExtension(name); + if( extension === ".ico" ) return true; + return FAVICON_EXTENSIONS.includes(extension) && name === "favicon" + extension; +} + +function imageMimeType(fileName) { + return MIME_TYPES[imageExtension(fileName)] || null; +} + +function imageLabelList() { + return IMAGE_EXTENSIONS.map(extension => extension.slice(1).toUpperCase()).join(", "); +} + +module.exports = { + IMAGE_EXTENSIONS, + FAVICON_EXTENSIONS, + COVER_EXTENSIONS, + IMAGE_FOLDER, + IMAGE_BACKGROUND_FOLDER, + IMAGE_CHARACTER_FOLDER, + LEGACY_IMAGE_SEARCH_FOLDERS, + LARGE_IMAGE_BYTES, + isImageFileName, + isFaviconFileName, + imageMimeType, + imageLabelList +}; diff --git a/app/renderer/imageViewer.js b/app/renderer/imageViewer.js new file mode 100644 index 00000000..c8c8f5c3 --- /dev/null +++ b/app/renderer/imageViewer.js @@ -0,0 +1,51 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { imageMimeType } = require("./imageTypes.js"); + +const $ = window.jQuery = require("./jquery-2.2.3.min.js"); +const $viewer = $("#image-viewer"); +const $editor = $("#editor"); +const $image = $viewer.find(".image-viewer-image"); +let loadId = 0; + +$image.on("error", () => { + $viewer.find(".image-viewer-status").text("This image could not be displayed."); +}); + +const ImageViewer = { + show: async filePath => { + const currentLoad = ++loadId; + const filename = path.basename(filePath); + const mimeType = imageMimeType(filename); + if( !mimeType ) throw new Error("Unsupported image format."); + + $viewer.prop("hidden", false); + $editor.hide(); + $viewer.find(".image-viewer-title").text(filename); + $viewer.find(".image-viewer-status").text("Loading image..."); + $image.removeAttr("src").attr("alt", filename); + + try { + const data = await fs.promises.readFile(filePath); + if( currentLoad !== loadId ) return; + $image.attr("src", `data:${mimeType};base64,${data.toString("base64")}`); + $viewer.find(".image-viewer-status").text(filePath); + } catch(error) { + if( currentLoad !== loadId ) return; + console.error("Could not open project image", filePath, error); + $viewer.find(".image-viewer-status").text(`Could not open image: ${error.message || error}`); + } + }, + hide: () => { + if( $viewer.prop("hidden") ) return; + loadId++; + $image.removeAttr("src"); + $viewer.prop("hidden", true); + $editor.show(); + ace.edit("editor").resize(); + } +}; + +module.exports = { ImageViewer }; diff --git a/app/renderer/index.html b/app/renderer/index.html index 8509a380..bed5cbec 100644 --- a/app/renderer/index.html +++ b/app/renderer/index.html @@ -15,7 +15,11 @@ - + + + + + @@ -84,26 +88,7 @@