From 9541a67f9c09c117070b4ffac4ce8d07848031a0 Mon Sep 17 00:00:00 2001 From: JimB16 Date: Tue, 12 Jul 2022 16:38:29 +0200 Subject: [PATCH 01/43] Updated german translation to 2.4 version --- public/locales/de.json | 389 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 346 insertions(+), 43 deletions(-) diff --git a/public/locales/de.json b/public/locales/de.json index 1764e9512..efaf4e206 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -1,108 +1,411 @@ { - "colors": {}, + "colors": { + "none": "Keine Farbe", + "red": "Rot", + "orange": "Orange", + "yellow": "Gelb", + "green": "Grün", + "blue": "Blau", + "purple": "Lila" + }, "common": { "add": "Hinzufügen", "appName": "Twine", + "back": "Zurück", + "build": "Build", "cancel": "Abbrechen", + "close": "Schließen", + "color": "Farbe", + "create": "Erstellen", + "custom": "Custom", "delete": "Löschen", + "deleteCount": "Lösche ({{count}})", + "details": "Details", "duplicate": "Duplizieren", "edit": "Bearbeiten", + "editCount": "Bearbeitung ({{count}})", + "help": "Hilfe", + "import": "Import", + "more": "Mehr", + "new": "Neu", + "next": "Weiter", "ok": "OK", + "passage": "Abschnitt", "play": "Spielen", + "preferences": "Einstellungen", + "publishToFile": "Als Datei veröffentlichen", + "redo": "Wiederherstellen", + "redoChange": "Wiederherstellen {{change}}", "rename": "Umbenennen", + "renamePrompt": "Wie soll “{{name}}” umbenannt werden?", "remove": "Entfernen", + "selectAll": "Alle auswählen", "skip": "Überspringen", + "story": "Geschichte", "storyFormat": "Geschichtsformat", "tag": "Tag", "test": "Testen", - "undo": "Rückgängig" + "twine": "Twine", + "undo": "Rückgängig", + "undoChange": "Rückgang {{change}}", + "view": "Ansicht" }, "components": { - "addStoryFormatButton": {}, - "addTagButton": {}, - "fontSelect": {"fonts": {}}, - "indentButtons": {}, - "localStorageQuota": {}, + "addTagButton": { + "alreadyAdded": "Dieser Tag Name wurde schon hinzugefügt.", + "addLabel": "Tag hinzufügen", + "invalidName": "Bitte gebe einen gültigen Tag Name ein.", + "newTag": "Neuer Tag", + "tagColorLabel": "Tag Farbe", + "tagNameLabel": "Tag Name" + }, + "dialogCard": { + "contentsCrashed": "Es gab ein Problem mit diesem Dialog. Versuche ihn zu schließen und nochmal zu öffnen." + }, + "fontSelect": { + "customScaleDetail": "Bitte gebe nur einen Prozentanteil ein.", + "customFamilyDetail": "Bitte gebe nur den Namen des Fonts ein.", + "familyEmpty": "Bitte gebe den Namen des Fonts ein.", + "font": "Font", + "fonts": { + "monospaced": "Festbreite", + "serif": "Serif", + "system": "System" + }, + "fontSize": "Font Größe", + "percentage": "{{percent}}%", + "percentageIsntNumber": "Bitte gebe eine Zahl ein.", + "percentageNotPositive": "Bitte gebe eine Zahl gößer als 0 ein." + }, + "indentButtons": { + "indent": "Einrücken", + "unindent": "Einrücken verkleinern" + }, + "localStorageQuota": { + "measureAgain": "Speicherplatz nochmal ermitteln", + "percentAvailable": "{percent}% Speicherplatz verfügbar" + }, "passageCard": { - "placeholderClick": "Doppelklicke auf diesen Abschnitt um ihn zu bearbeiten.", - "placeholderTouch": "Tippe auf den Abschnitt und danach auf das Stift-Symbol, um ihn zu bearbeiten." - }, - "renamePassageButton": {"emptyName": "Bitte gebe einen Namen ein."}, - "renameStoryButton": {"emptyName": "Bitte gebe einen Namen ein."}, - "safariWarningCard": {}, - "storyCard": {}, - "storyFormatCard": {}, - "storyFormatSelect": {}, - "tagEditor": {} + "placeholderClick": "Doppelklick auf diesen Abschnitt um ihn zu bearbeiten.", + "placeholderTouch": "Tippe auf den Abschnitt, dann gehe auf Bearbeiten im Abschnittstab um ihn zu bearbeiten." + }, + "renamePassageButton": { + "emptyName": "Bitte gebe einen Namen ein.", + "nameAlreadyUsed": "Ein anderer Abschnitt dieser Geschichte hat diesen Namen." + }, + "renameStoryButton": { + "emptyName": "Bitte gebe einen Namen ein.", + "nameAlreadyUsed": "Eine andere Geschichte hat diesen Namen." + }, + "safariWarningCard": { + "archiveAndUseAnotherBrowser": "Bitte archiviere deine Geschichten und benutze eine andere Platform.", + "addToHomeScreen": "Füge diese Seite deinem Home Screen hinzu um diese Limitierung zu umgehen.", + "howToAddToHomeScreen": "Wie füge ich dies meinem Home Screen hinzu?", + "learnMore": "Mehr erfahren", + "message": "Der Browser den du benutzt löscht alle Geschichten nach sieben Tagen an denen du diese Webseite nicht besuchst." + }, + "storageQuota": { + "freeSpace": "{{percent}}% Speicherplatz verfügbar" + }, + "storyCard": { + "lastUpdated": "Zuletzt bearbeitet am {{date}}", + "passageCount": "1 Abschnitt", + "passageCount_plural": "{{count}} Abschnitte" + }, + "storyFormatCard": { + "author": "von {{author}}", + "builtIn": "Erstellt am", + "defaultFormat": "Als Standard gesetzt", + "editorExtensionsDisabled": "Editor Erweiterungen Deaktiviert", + "license": "Lizent: {{license}}", + "loadingFormat": "Lade diese Geschichtsformat...", + "loadError": "Dieses Geschichtsformat konnte nicht geladen werden ({{errorMessage}}).", + "name": "{{name}} {{version}}", + "proofing": "Korrektur", + "proofingFormat": "Benutzt für Korrektur", + "useEditorExtensions": "Benutze Editor Erweiterungen", + "useFormat": "Benutze Als Standard Geschichtsformat", + "useProofingFormat": "Benutze Als Korrektur Format" + }, + "storyFormatSelect": { + "loadingCount": "Lade 1 Geschichtsformat...", + "loadingCount_plural": "Lade {{loadingCount}} Geschichtsformate..." + }, + "tagEditor": { + "alreadyExists": "Ein Tag mit diesem Namen existiert bereits." + } }, "dialogs": { - "aboutTwine": {"donateToTwine": "Unterstütze Twine mit einer Spende"}, - "appDonation": {"noThanks": "Nein danke"}, - "appPrefs": {"language": "Sprache"}, - "passageEdit": {}, - "passageTags": {}, - "storyInfo": {"stats": {"title": "Statistiken der Geschichte"}}, + "aboutTwine": { + "donateToTwine": "Unterstütze Twine mit einer Spende", + "codeHeader": "Code", + "codeRepo": "Besuche Source Code Repository", + "license": "Dieses Programm wurde unter der GPL v3 Lizenz veröffentlicht, jedoch darf jedes hiermit erstellte Werk unter frei gewählten Bedingungen veröffentlicht werden, auch kommerzielle.", + "localizationHeader": "Lokalisierungen", + "title": "Über Twine {{version}}", + "twineDescription": "Twine ist eine Open-Source Software um interaktive, nicht-lineare Geschichten zu erzählen." + }, + "appDonation": { + "donate": "Spende Für Twine Entwicklung", + "onlyOnce": "(Diese Nachricht wird dir nur einmal angezeigt. Wenn du in der Zukunft für die Twine Entwicklung spenden möchtest, findest du einen Link im Über Twine Dialog.)", + "supportMessage": "Wenn du Twine liebst, dann bitte denk darüber nach es mit einer Spende beim wachsen zu unterstützen. Twine ist ein Open-Source Projekt das immer frei benutzbar sein wird - und mit deiner Hilfe wird es weiter gedeihen.", + "noThanks": "Nein danke", + "title": "Unterstütze die Twine Entwicklung" + }, + "appPrefs": { + "codeEditorFont": "Code Editor Font", + "codeEditorFontScale": "Code Editor Font Größe", + "editorCursorBlinks": "Blinkender Cursor in den Editoren", + "fontExplanation": "Den Font hier zu ändern betrifft nur den Twine Editor. Es wird nicht den Font den eine Geschichte beim spielen benutzt ändern.", + "language": "Sprache", + "passageEditorFont": "Abschnitts Editor Font", + "passageEditorFontScale": "Abschnitts Editor Font Größe", + "themeLight": "Hell", + "themeDark": "Dunkel", + "themeSystem": "System", + "theme": "Thema", + "title": "Einstellungen" + }, + "passageEdit": { + "editorCrashed": "Etwas ist mit diesem Editor schief gelaufen. Versuche ihn zu schließen und editiere diesen Abschnitt nochmal.", + "passageTextEditorLabel": "Text des Abschnitts", + "passageTextPlaceholder": "Gebe den Text deines Abschnitts hier ein. Um einen anderen Abschnitt zu verlinken setze zwei eckige Klammern um dessen Namen, [[sowie hier]].", + "setAsStart": "Beginne Die Geschichte Hier", + "size": "Größe", + "sizeLarge": "Groß", + "sizeSmall": "Klein", + "sizeTall": "Hoch", + "sizeWide": "Breit" + }, + "passageTags": { + "noTags": "Kein Abschnitt dieser Geschichte hat einen Tag bisher.", + "title": "Abschnitt Tags" + }, + "storyImport": { + "deselectAll": "Alle entmarkieren", + "filePrompt": "Um Geschichten nach Twine zu importieren, lade unten entweder ein Archiv oder eine Datei einer veröffentlichen Geschichte hoch.", + "importDifferentFile": "Importiere eine andere Datei", + "importSelected": "Importiere die ausgewählten Dateien", + "importThisStory": "Importiere Diese Geschichte", + "noStoriesInFile": "Die Datei die du hochgeladen hast scheint keine Twine Geschichten zu beinhalten. Bitte wähle eine andere Datei.", + "storiesPrompt": "Wähle Geschichten zum importieren:", + "title": "Importiere Geschichten", + "willReplaceExisting": "Eine Geschichte mit dem selben Namen in deiner Bibliothek wird ersetzt." + }, + "storyDetails": { + "storyFormatExplanation": "Was ist ein Geschichtsformat?", + "snapToGrid": "Am Raster Ausrichten", + "stats": { + "brokenLinks": "Tote Links", + "characters": "Zeichen", + "title": "Statistiken der Geschichte", + "ifid": "Die IFID dieser Geschichte ist {{ifid}}.", + "ifidExplanation": "Was ist eine IFID?", + "lastUpdate": "Diese Geschichte wurde zuletzt geändert am {{date}}.", + "links": "Links", + "passages": "Abschnitte", + "words": "Wörter" + } + }, "storyJavaScript": { + "editorLabel": "JavaScript der Geschicht", + "title": "JavaScript der Geschicht", "explanation": "Jegliches hier eingegebene JavaScript wird sofort ausgeführt, wenn Deine Geschichte in einem Browser geöffnet wird." }, "storySearch": { "title": "Suchen und Ersetzen", - "replaceWith": "Ersetzen mit" + "find": "Suche", + "includePassageNames": "Namen der Abschnitte durchsuchen", + "matchCase": "Groß-/Kleinschreibung beachten", + "matchCount": "{{count}} gefundenen Abschnitt", + "matchCount_plural": "{{count}} gefundenen Abschnitte", + "noMatches": "Keine gefundenen Abschnitte", + "replaceAll": "Ersetze in allen Abschnitten", + "replaceWith": "Ersetzen mit", + "useRegexes": "Benutze Reguläre Ausdrücke" }, "storyStylesheet": { - "explanation": "Jedes CSS an dieser Stelle überschreibt das Standard-Aussehen Deiner Geschichte." + "editorLabel": "Stylesheet der Geschichte", + "title": "Stylesheet der Geschichte", + "explanation": "Jedes CSS an dieser Stelle überschreibt das Standard-Aussehen deiner Geschichte." }, - "storyTags": {} + "storyTags": { + "noTags": "Deine Geschichte enthält keine Tags.", + "title": "Tags der Geschichte" + } }, "electron": { - "errors": {"storyFileChangedExternally": {}}, - "menuBar": {"edit": "Bearbeiten"}, - "storiesDirectoryName": "Geschichten" + "backupsDirectoryName": "Backups", + "errors": { + "jsonSave": "Etwas ging schief beim Speichern einer Einstellungsdatei.", + "storyFileChangedExternally": { + "message": "Die Datei “{{fileName}}” in deiner Geschichsbibliothek wurde außerhalb von Twine geändert.", + "detail": "Änderungen speichern wird diese Datei überschreiben. Wenn du lieber diese Datei anstelle der Version von Twine verwenden willst, dann wird Twine neustarten und deine Arbeit wird mit dieser Datei ersetzt.", + "overwriteChoice": "Speicher Änderungen in Twine", + "relaunchChoice": "Benutze Datei und starte neu" + }, + "storyDelete": "Etwas ging schief beim Löschen einer Geschichte.", + "storyRename": "Etwas ging schief beim Umbenennen einer Geschichte.", + "storySave": "Etwas ging schief beim Speichern einer Geschichte." + }, + "menuBar": { + "checkForUpdates": "Check for Updates...", + "edit": "Bearbeiten", + "showDevTools": "Zeige die Debug Konsole", + "showStoryLibrary": "Zeige die Geschichtsbibliothek", + "speech": "Speech", + "troubleshooting": "Fehlerbehebung", + "twineHelp": "Twine Hilfe", + "view": "Ansicht" + }, + "storiesDirectoryName": "Geschichten", + "updateCheck": { + "download": "Download", + "error": "Etwas ging schief beim prüfen nach einer neueren Version von Twine.", + "updateAvailable": "Eine neuere Version von Twine ist verfügbar.", + "upToDate": "Dies ist die neueste Version von Twine die verfügbar ist." + } }, "routes": { "storyEdit": { + "toolbar": { + "findAndReplace": "Suchen und Ersetzen", + "javaScript": "JavaScript", + "passageTags": "Abschnitt Tags", + "snapToGrid": "Am Raster ausrichten", + "startStoryHere": "Beginne Die Geschichte Hier", + "stylesheet": "Stylesheet", + "testFromHere": "Teste Ab Hier" + }, "topBar": { - "addPassage": "Abschnitt", "editJavaScript": "JavaScript der Geschichte bearbeiten", "editStylesheet": "Stylesheet der Geschichte bearbeiten", "findAndReplace": "Suchen und Ersetzen", + "passageTags": "Bearbeite die Tags des Abschnitts", "proofStory": "Korrekturfassung anzeigen", - "publishToFile": "Als Datei veröffentlichen" + "publishToFile": "Als Datei veröffentlichen", + "selectAllPassages": "Wähle Alle Abschnitte Aus" + }, + "zoomButtons": { + "storyStructure": "Zeige Nur Die Struktur Der Geschichte", + "passageNames": "Zeige Nur Die Namen der Abschnitte", + "passageNamesAndExcerpts": "Zeige Abschnittsnamen und -ausschnitte" } }, "storyFormatList": { - "title": {}, + "noneVisible": "Keine Geschichtsformate passen zu deinen Kriterien.", + "show": "Anzeigen...", + "title": { + "all": "Alle Geschichtsformate", + "current": "Momentane Geschichtsformate", + "user": "Vom Benutzer hinzugefügte Geschichtsformate" + }, + "toolbar": { + "addStoryFormatButton": { + "addPreview": "{{storyFormatName}} {{storyFormatVersion}} wird hinzugefügt.", + "alreadyAdded": "{{storyFormatName}} {{storyFormatVersion}} ist bereits vorhanden.", + "fetchError": "Das Geschichtsformat an dieser Adresse konnte nicht abgerufen werden ({errorMessage}).", + "invalidUrl": "Bitte eine gültige URL eingeben.", + "prompt": "Um ein Geschichtsformat hinzuzufügen, gebe unten die Adresse ein." + }, + "disableFormatExtensions": "Deaktiviere Editor Erweiterungen", + "enableFormatExtensions": "Aktiviere Editor Erweiterungen", + "useAsDefaultFormat": "Benutze als Standardformat", + "useAsProofingFormat": "Benutze zur Korrektur von Geschichten" + }, "storyFormatExplanation": "Geschichtsformate bestimmen Aussehen und Verhalten Deiner Geschichten während des Spielens." }, - "storyImport": {}, "storyList": { + "library": "Bibliothek", "noStories": "Es sind derzeit noch keine Geschichten in Twine gespeichert. Du kannst entweder eine neue Geschichte erstellen oder eine bestehende Geschichte importieren.", + "taggedTitleCount": "1 Geschichte mit Tags", + "taggedTitleCount_0": "Keine Geschichten mit Tags", + "taggedTitleCount_plural": "{{count}} Geschichten mit Tags", + "titleCount": "1 Geschichte", + "titleCount_0": "Keine Geschichten", + "titleCount_plural": "{{count}} Geschichten", "titleGeneric": "Geschichten", - "topBar": { - "about": "Über Twine", + "toolbar": { "archive": "Archiv", - "createStory": "Geschichte", - "help": "Hilfe", - "sortName": "Name", - "storyFormats": "Formate" + "createStoryButton": { + "prompt": "Wie soll deine Geschichte heißen? Du kannst das später ändern.", + "emptyName": "Bitte gebe einen Namen ein.", + "nameConflict": "Eine andere Geschichte hat schon diesen Namen." + }, + "deleteStoryButton": { + "warning": { + "electron": "Bist du sicher das du “{{storyName}}” löschen willst? Es wird in den Papierkorb verschoben.", + "web": "Bist du sicher das du “{{storyName}}” löschen willst? Es wird für immer gelöscht. Dies kann nicht rückgängig gemacht werden." + } + }, + "showAllStories": "Zeige alle Geschichten", + "showTags": "Tags anzeigen", + "sort": "Sortieren nach", + "sortByDate": "Zuletzt geändert", + "sortByName": "Name", + "storyTags": "Tags von Geschichten" } }, "welcome": { + "autosave": "

Es ist nun ein Ordner mit dem Namen Twine in deinem Dokumenten Ordner. Darin ist ein Geschichten Ordner, wo all deine Arbeit gespeichert wird. Twine speichert während du arbeitest, so dass du dir keine Sorgen darum machen musst daran zu denken selber zu speichern. Du kannst immer den Ordner, wo deine Geschichten gespeicher werden, öffnen wenn du die Option Zeige Bibliothek im Twine Menü.

Da Twine immer deine Arbeit speichert sind die Dateien in deiner Geschichtsbibliothek vor Änderungen gesperrt solange Twine offen ist.

Wenn du eine Twine Geschichts Datei die du von jemand anderen erhalten hast öffnen möchtest, kannst du diese mit Hilfe des Von Datei Importieren Links in der Übersicht Deiner Geschichten in deine Bibliothek importieren.

", "autosaveTitle": "Deine Arbeit wird automatisch gespeichert.", + "browserStorage": "

Das bedeutet du brauchst keinen Account zu erstellen um Twine 2 zu benutzen, und alles was du erstellst ist nicht irgendwo auf einem Server gespeichert—alles bleibt in deinem Browser.

Zwei sehr wichtige Dinge du jedoch im Kopf behalten solltest. Da deine Arbeit nur in deinem Browser gespeichert wird, verlierst du auch deine Arbeit falls du die gespeicherte Daten deines Browser löschen solltest! Das wäre nicht gut. Deshalb denk daran den Archiv Button oft zu benutzen. Du kannst auch individuelle Geschichten in Dateien veröffentlichen wenn du das Menü auf jeder Geschichte in der Übersicht deiner Geschichten benutzt. Beide Archive und Geschichtsdateien können jederzeit in Twine zurück importiert werden.

Zweitens, jeder der diesen Browser benutzen kann, kann deine Arbeit sehen und ändern. Wenn du also einen nervenden kleinen Bruder hast, sieh zu das du ein seperates Profil für dich selbst erstellst.

", + "browserStorageTitle": "Deine Arbeit wird nur in deinem Browser gespeichert", + "done": "

Vielen Dank fürs lesen, und viel Spaß mit Twine.

", "doneTitle": "Das ist alles!", "gotoStoryList": "Gehe zur Übersicht Deiner Geschichten", + "greeting": "

Twine ist ein Open-Source Software zum erzählen interaktiver, nicht-linearer Geschichten. Es gibt ein paar Dinge die du wissen solltest bevor du anfängst.

", "greetingTitle": "Hallo!", "tellMeMore": "Mehr erfahren", + "help": "

Falls du noch nie Twine vorher benutzt hast, Willkommen! Das Twine Cookbook ist ein großartiges Hilfsmittel um zu lernen wie man das hier benutzt. Falls du Twine vorher noch nicht benutzt hast, ist das ein guter Ort um anzufangen.

", "helpTitle": "Neu hier?" } }, + "routeActions": { + "app": { + "aboutApp": "Über Twine", + "preferences": "Einstellungen", + "reportBug": "Melde einen Fehler", + "storyFormats": "Geschichtsformate" + }, + "build": { + "play": "Spiele", + "proof": "Korrektur", + "publishToFile": "Als Datei veröffentlichen", + "test": "Test" + } + }, "store": { - "errors": {}, + "archiveFilename": "{{timestamp}} Twine Archiv.html", + "errors": { + "cantPersistPrefs": "Etwas ging schief beim Speichern deiner Einstellungen ({{error}}).", + "cantPersistStories": "Etwas ging schief beim Speichern deiner Geschichten ({{error}}).", + "cantPersistStoryFormats": "Etwas ging schief beim Speichern deiner Geschichtsformate ({{error}}).", + "electronRemediation": "Diese Anwendung neu zu starten könnte helfen.", + "webRemediation": "Diese Seite neu zu laden könnte helfen." + }, "passageDefaults": { "name": "Unbenannter Abschnitt" }, - "storyDefaults": {"name": "Unbenannte Geschichte"}, - "storyFormatDefaults": {"name": "Unbenanntes Geschichtsformat"} + "storyDefaults": { + "name": "Unbenannte Geschichte" + }, + "storyFormatDefaults": { + "name": "Unbenanntes Geschichtsformat" + } }, - "undoChange": {"replaceAllText": "Alle ersetzen"} + "undoChange": { + "addTag": "Tag hinzufügen", + "changeTagColor": "Ändere Tag Farbe", + "newPassage": "Neuer Abschnitt", + "deletePassage": "Lösche Abschnitt", + "deletePassages": "Lösche Abschnitte", + "movePassage": "Bewege Abschnitt", + "movePassages": "Bewege Abschnitte", + "imortTag": "Entferne Tag", + "renamePassage": "Ändere Abschnittsname", + "removeTag": "Tag entfernen", + "renameTag": "Tag umbenennen", + "replaceAllText": "Alle ersetzen" + } } From 3a95422ef7e754d47f5d04bd28e7007a9da3f592 Mon Sep 17 00:00:00 2001 From: Julian Brehmer Date: Tue, 12 Jul 2022 17:09:12 +0200 Subject: [PATCH 02/43] small corrections to translations --- public/locales/de.json | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/public/locales/de.json b/public/locales/de.json index efaf4e206..cbe0fd3b7 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -54,7 +54,7 @@ "addTagButton": { "alreadyAdded": "Dieser Tag Name wurde schon hinzugefügt.", "addLabel": "Tag hinzufügen", - "invalidName": "Bitte gebe einen gültigen Tag Name ein.", + "invalidName": "Bitte gib einen gültigen Tag Namen ein.", "newTag": "Neuer Tag", "tagColorLabel": "Tag Farbe", "tagNameLabel": "Tag Name" @@ -63,9 +63,9 @@ "contentsCrashed": "Es gab ein Problem mit diesem Dialog. Versuche ihn zu schließen und nochmal zu öffnen." }, "fontSelect": { - "customScaleDetail": "Bitte gebe nur einen Prozentanteil ein.", - "customFamilyDetail": "Bitte gebe nur den Namen des Fonts ein.", - "familyEmpty": "Bitte gebe den Namen des Fonts ein.", + "customScaleDetail": "Bitte gib nur einen Prozentanteil ein.", + "customFamilyDetail": "Bitte gib nur den Namen des Fonts ein.", + "familyEmpty": "Bitte gib den Namen des Fonts ein.", "font": "Font", "fonts": { "monospaced": "Festbreite", @@ -74,8 +74,8 @@ }, "fontSize": "Font Größe", "percentage": "{{percent}}%", - "percentageIsntNumber": "Bitte gebe eine Zahl ein.", - "percentageNotPositive": "Bitte gebe eine Zahl gößer als 0 ein." + "percentageIsntNumber": "Bitte gib eine Zahl ein.", + "percentageNotPositive": "Bitte gib eine Zahl gößer als 0 ein." }, "indentButtons": { "indent": "Einrücken", @@ -90,11 +90,11 @@ "placeholderTouch": "Tippe auf den Abschnitt, dann gehe auf Bearbeiten im Abschnittstab um ihn zu bearbeiten." }, "renamePassageButton": { - "emptyName": "Bitte gebe einen Namen ein.", + "emptyName": "Bitte gib einen Namen ein.", "nameAlreadyUsed": "Ein anderer Abschnitt dieser Geschichte hat diesen Namen." }, "renameStoryButton": { - "emptyName": "Bitte gebe einen Namen ein.", + "emptyName": "Bitte gib einen Namen ein.", "nameAlreadyUsed": "Eine andere Geschichte hat diesen Namen." }, "safariWarningCard": { @@ -148,7 +148,7 @@ "appDonation": { "donate": "Spende Für Twine Entwicklung", "onlyOnce": "(Diese Nachricht wird dir nur einmal angezeigt. Wenn du in der Zukunft für die Twine Entwicklung spenden möchtest, findest du einen Link im Über Twine Dialog.)", - "supportMessage": "Wenn du Twine liebst, dann bitte denk darüber nach es mit einer Spende beim wachsen zu unterstützen. Twine ist ein Open-Source Projekt das immer frei benutzbar sein wird - und mit deiner Hilfe wird es weiter gedeihen.", + "supportMessage": "Wenn du Twine magst, dann bitte denk darüber nach die Entwicklung mit einer Spende zu unterstützen. Twine ist ein Open-Source Projekt das immer frei benutzbar sein wird - und mit deiner Hilfe wird es weiter wachsen und gedeihen.", "noThanks": "Nein danke", "title": "Unterstütze die Twine Entwicklung" }, @@ -182,7 +182,7 @@ "title": "Abschnitt Tags" }, "storyImport": { - "deselectAll": "Alle entmarkieren", + "deselectAll": "Alle Markierungen aufheben", "filePrompt": "Um Geschichten nach Twine zu importieren, lade unten entweder ein Archiv oder eine Datei einer veröffentlichen Geschichte hoch.", "importDifferentFile": "Importiere eine andere Datei", "importSelected": "Importiere die ausgewählten Dateien", @@ -251,7 +251,7 @@ "menuBar": { "checkForUpdates": "Check for Updates...", "edit": "Bearbeiten", - "showDevTools": "Zeige die Debug Konsole", + "showDevTools": "Öffne die Debug Konsole", "showStoryLibrary": "Zeige die Geschichtsbibliothek", "speech": "Speech", "troubleshooting": "Fehlerbehebung", @@ -329,7 +329,7 @@ "archive": "Archiv", "createStoryButton": { "prompt": "Wie soll deine Geschichte heißen? Du kannst das später ändern.", - "emptyName": "Bitte gebe einen Namen ein.", + "emptyName": "Bitte einen Namen eingeben.", "nameConflict": "Eine andere Geschichte hat schon diesen Namen." }, "deleteStoryButton": { @@ -381,8 +381,8 @@ "cantPersistPrefs": "Etwas ging schief beim Speichern deiner Einstellungen ({{error}}).", "cantPersistStories": "Etwas ging schief beim Speichern deiner Geschichten ({{error}}).", "cantPersistStoryFormats": "Etwas ging schief beim Speichern deiner Geschichtsformate ({{error}}).", - "electronRemediation": "Diese Anwendung neu zu starten könnte helfen.", - "webRemediation": "Diese Seite neu zu laden könnte helfen." + "electronRemediation": "Es könnte helfen diese Anwendung neu zu starten.", + "webRemediation": "Es könnte helfen diese Seite neu zu laden." }, "passageDefaults": { "name": "Unbenannter Abschnitt" From 5e2ed24b62fbd0a209a636803651885338e2be80 Mon Sep 17 00:00:00 2001 From: Julian Brehmer Date: Tue, 12 Jul 2022 18:57:11 +0200 Subject: [PATCH 03/43] a few german translation fixes --- public/locales/de.json | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/public/locales/de.json b/public/locales/de.json index cbe0fd3b7..96a609217 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -83,10 +83,10 @@ }, "localStorageQuota": { "measureAgain": "Speicherplatz nochmal ermitteln", - "percentAvailable": "{percent}% Speicherplatz verfügbar" + "percentAvailable": "{percent}% Speicherplatz frei" }, "passageCard": { - "placeholderClick": "Doppelklick auf diesen Abschnitt um ihn zu bearbeiten.", + "placeholderClick": "Doppelklicke auf diesen Abschnitt um ihn zu bearbeiten.", "placeholderTouch": "Tippe auf den Abschnitt, dann gehe auf Bearbeiten im Abschnittstab um ihn zu bearbeiten." }, "renamePassageButton": { @@ -105,7 +105,7 @@ "message": "Der Browser den du benutzt löscht alle Geschichten nach sieben Tagen an denen du diese Webseite nicht besuchst." }, "storageQuota": { - "freeSpace": "{{percent}}% Speicherplatz verfügbar" + "freeSpace": "{{percent}}% Speicherplatz frei" }, "storyCard": { "lastUpdated": "Zuletzt bearbeitet am {{date}}", @@ -117,7 +117,7 @@ "builtIn": "Erstellt am", "defaultFormat": "Als Standard gesetzt", "editorExtensionsDisabled": "Editor Erweiterungen Deaktiviert", - "license": "Lizent: {{license}}", + "license": "Lizenz: {{license}}", "loadingFormat": "Lade diese Geschichtsformat...", "loadError": "Dieses Geschichtsformat konnte nicht geladen werden ({{errorMessage}}).", "name": "{{name}} {{version}}", @@ -140,7 +140,7 @@ "donateToTwine": "Unterstütze Twine mit einer Spende", "codeHeader": "Code", "codeRepo": "Besuche Source Code Repository", - "license": "Dieses Programm wurde unter der GPL v3 Lizenz veröffentlicht, jedoch darf jedes hiermit erstellte Werk unter frei gewählten Bedingungen veröffentlicht werden, auch kommerzielle.", + "license": "Diese Anwendung ist unter der GPL v3 Lizenz veröffentlicht, aber ein Werk, das mit dieser Anwendung erstellt wurde, darf unter einer beliebigen auch kommerziellen Lizenz veröffentlicht werden.", "localizationHeader": "Lokalisierungen", "title": "Über Twine {{version}}", "twineDescription": "Twine ist eine Open-Source Software um interaktive, nicht-lineare Geschichten zu erzählen." @@ -148,7 +148,7 @@ "appDonation": { "donate": "Spende Für Twine Entwicklung", "onlyOnce": "(Diese Nachricht wird dir nur einmal angezeigt. Wenn du in der Zukunft für die Twine Entwicklung spenden möchtest, findest du einen Link im Über Twine Dialog.)", - "supportMessage": "Wenn du Twine magst, dann bitte denk darüber nach die Entwicklung mit einer Spende zu unterstützen. Twine ist ein Open-Source Projekt das immer frei benutzbar sein wird - und mit deiner Hilfe wird es weiter wachsen und gedeihen.", + "supportMessage": "Wenn dir Twine am Herzen liegt, dann bitte denk darüber nach die Entwicklung mit einer Spende zu unterstützen. Twine ist ein Open-Source-Projekt, das immer frei zur Verfügung stehen wird und mit deiner Hilfe weiter wachsen kann.", "noThanks": "Nein danke", "title": "Unterstütze die Twine Entwicklung" }, @@ -217,8 +217,8 @@ "find": "Suche", "includePassageNames": "Namen der Abschnitte durchsuchen", "matchCase": "Groß-/Kleinschreibung beachten", - "matchCount": "{{count}} gefundenen Abschnitt", - "matchCount_plural": "{{count}} gefundenen Abschnitte", + "matchCount": "{{count}} Abschnitt stimmt überein", + "matchCount_plural": "{{count}} Abschnitte stimmen überein", "noMatches": "Keine gefundenen Abschnitte", "replaceAll": "Ersetze in allen Abschnitten", "replaceWith": "Ersetzen mit", @@ -335,7 +335,7 @@ "deleteStoryButton": { "warning": { "electron": "Bist du sicher das du “{{storyName}}” löschen willst? Es wird in den Papierkorb verschoben.", - "web": "Bist du sicher das du “{{storyName}}” löschen willst? Es wird für immer gelöscht. Dies kann nicht rückgängig gemacht werden." + "web": "Bist du sicher das du “{{storyName}}” löschen willst? Es wird unwiderruflich gelöscht. Dies kann nicht rückgängig gemacht werden." } }, "showAllStories": "Zeige alle Geschichten", @@ -347,9 +347,9 @@ } }, "welcome": { - "autosave": "

Es ist nun ein Ordner mit dem Namen Twine in deinem Dokumenten Ordner. Darin ist ein Geschichten Ordner, wo all deine Arbeit gespeichert wird. Twine speichert während du arbeitest, so dass du dir keine Sorgen darum machen musst daran zu denken selber zu speichern. Du kannst immer den Ordner, wo deine Geschichten gespeicher werden, öffnen wenn du die Option Zeige Bibliothek im Twine Menü.

Da Twine immer deine Arbeit speichert sind die Dateien in deiner Geschichtsbibliothek vor Änderungen gesperrt solange Twine offen ist.

Wenn du eine Twine Geschichts Datei die du von jemand anderen erhalten hast öffnen möchtest, kannst du diese mit Hilfe des Von Datei Importieren Links in der Übersicht Deiner Geschichten in deine Bibliothek importieren.

", + "autosave": "

Es gibt nun einen Twine-Ordner in deinem Dokumente-Ordner. Dieser enthält einen Geschichten-Ordner, indem all deine Arbeiten gespeichert werden. Twine speichert automatisch beim Schreiben der Geschichte, so dass du dir keine Sorgen darum machen musst daran zu denken manuell zu speichern. Du kannst den Ordner, wo deine Geschichten gespeichert werden, immer mit der Option Zeige Bibliothek im Twine Menü öffnen.

Da Twine deine Arbeit regelmäßig speichert, sind die Dateien in deiner Bibliothek vor Bearbeitung gesperrt, solange Twine geöffnet ist.

Wenn du eine Twine Geschichts-Datei die du von jemand anderen erhalten hast öffnen möchtest, kannst du diese mit Hilfe des Von Datei Importieren Links in der Übersicht Deiner Geschichten in deine Bibliothek importieren.

", "autosaveTitle": "Deine Arbeit wird automatisch gespeichert.", - "browserStorage": "

Das bedeutet du brauchst keinen Account zu erstellen um Twine 2 zu benutzen, und alles was du erstellst ist nicht irgendwo auf einem Server gespeichert—alles bleibt in deinem Browser.

Zwei sehr wichtige Dinge du jedoch im Kopf behalten solltest. Da deine Arbeit nur in deinem Browser gespeichert wird, verlierst du auch deine Arbeit falls du die gespeicherte Daten deines Browser löschen solltest! Das wäre nicht gut. Deshalb denk daran den Archiv Button oft zu benutzen. Du kannst auch individuelle Geschichten in Dateien veröffentlichen wenn du das Menü auf jeder Geschichte in der Übersicht deiner Geschichten benutzt. Beide Archive und Geschichtsdateien können jederzeit in Twine zurück importiert werden.

Zweitens, jeder der diesen Browser benutzen kann, kann deine Arbeit sehen und ändern. Wenn du also einen nervenden kleinen Bruder hast, sieh zu das du ein seperates Profil für dich selbst erstellst.

", + "browserStorage": "

Das bedeutet, du benötigst keinen Account um Twine 2 zu verwenden. Alles was du erstellst wird nicht auf irgendeinem Server abgelegt—sondern verbleibt lokal in deinem Browser gespeichert.

Zwei sehr wichtige Dinge sind jedoch zu beachten! Da deine Arbeit nur in deinem Browser gespeichert wird, verlierst du auch deine Arbeit falls du die gespeicherte Daten deines Browser löschen solltest! Deshalb denk daran den Archiv-Button häufig zu verwenden. Du kannst einzelne Geschichten auch in Dateien veröffentlichen wenn du das jeweilige Menü in der Übersicht deiner Geschichten aufrufst. Sowohl das Archiv als auch die gesicherten Geschichten können jederzeit in Twine reimportiert werden.

Außerdem, jeder der diesen Browser verwendet, kann deine Arbeiten sehen und verändern. Wenn du also deinen Computer mit neugierigen Mitmenschen teilst, lege am besten ein eigenes, seperates Benutzerprofil für dich an.

", "browserStorageTitle": "Deine Arbeit wird nur in deinem Browser gespeichert", "done": "

Vielen Dank fürs lesen, und viel Spaß mit Twine.

", "doneTitle": "Das ist alles!", @@ -357,7 +357,7 @@ "greeting": "

Twine ist ein Open-Source Software zum erzählen interaktiver, nicht-linearer Geschichten. Es gibt ein paar Dinge die du wissen solltest bevor du anfängst.

", "greetingTitle": "Hallo!", "tellMeMore": "Mehr erfahren", - "help": "

Falls du noch nie Twine vorher benutzt hast, Willkommen! Das Twine Cookbook ist ein großartiges Hilfsmittel um zu lernen wie man das hier benutzt. Falls du Twine vorher noch nicht benutzt hast, ist das ein guter Ort um anzufangen.

", + "help": "

Wenn du Twine noch nie benutzt hast, Herzlich Willkommen! Das Twine Cookbook ist ein großartiges Hilfsmittel um zu lernen wie man Twine benutzt. Falls du Twine vorher noch nicht benutzt hast, ist das ein guter Ort für den Einstieg.

", "helpTitle": "Neu hier?" } }, From bf6eccf729c56e7e492dc8abac268327b0483b8b Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Mon, 5 Sep 2022 15:44:12 -0400 Subject: [PATCH 04/43] Add design goals, update contribution guidelines --- .github/pull_request_template.md | 11 +- CONTRIBUTING.md | 110 ++++++++++++++-- DESIGN_GOALS.md | 119 ++++++++++++++++++ .../__tests__/story-test-route.test.tsx | 2 +- 4 files changed, 227 insertions(+), 15 deletions(-) create mode 100644 DESIGN_GOALS.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 8f21d94fb..4e580cdf3 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,13 +1,12 @@ -🛑 **Only PRs related to localization are being accepted for Twine 2.4 right now.** \ -🛑 **Only PRs that fix bugs will be accepted for Twine 2.3. No PRs that add new features or modify existing functionality will be accepted.** - # Description Enter a short description of what this PR does. -# Issues fixed +# Issues Fixed + +_If you are contributing a localization, you can skip this section._ -Link any issues that this PR resolves. +Link the issues that this PR resolves. Your PR must resolve at least one issue, and agreement must be reached in the issue on your implementation approach before opening this PR. # Credit @@ -16,7 +15,7 @@ Please put an X in *one* of the squares below only. [ ] I would like to be credited in the application as: ______ \ [ ] I would not like my name to appear in the application credits. -# Presubmission checklist +# Presubmission Checklist Put an X in the squares below to indicate you've done each step. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d57766085..bd054f827 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,11 +1,97 @@ -# Contributing code +# Contributing Code -Sorry, the 2.4 branch is still not quite ready for code contributions yet! Stay -tuned. +## Process -Bug fixes and updates for the built-in story formats are being accepted for the 2.3 branch. Please open a PR against the `2.3-maintenance` branch. +In general, the _last_ step in the process of contributing code is to open a PR +on this repository. The reason why is that writing code is time-consuming, and +it's better to get agreement on the implementation approach early instead of +having to make considerable revisions. -# Contributing localizations +## Bugfixes + +Please first open an issue on this repo and check the box in the issue form +indicating that you would like to work on a fix. We'll need to come to agreement +on how to fix the issue in discussion on that issue. For minor or obvious bugs, +this discussion should be very straightforward. + +Once agreement has been reached, a PR can be opened; please mention the issue +number in the PR's description. It will be assigned to a GitHub project for the +release it will be targeted to, so you can track the progress of a PR toward a +finished release. + +## New Features and Enhancements + +Please read [Twine's design goals] first. + +If you think your idea meshes with these goals, open an issue on this repo and +check the box in the issue form indicating you would like to work on a fix. We +will need to discuss your idea in detail and come to agreement on how it will +work. This process will likely require you to provide mock screenshots and +explain how users will use the feature in detail. + +Once we have come to agreement on the UI and implementation approach, a PR can +be opened. Please mention the issue number in the PR's description. As with +bugfixes, PRs are assigned to GitHub projects to track releases. + +## 2.3 + +PRs are no longer being accepted for the 2.3 release branch. Members of the +community who would like to continue to update 2.3 are welcome to do so in a +forked version of the app. (If you decide to do this, please use a different +name than Twine for the app.) + +## PR Practices + +PRs should always: + +- Target the `develop` branch, not `main`. +- Include sufficient unit test coverage. You can see a test coverage report by + running `npm run test:coverage`. A good guideline to deciding whether test + coverage is enough is to ask yourself, "Could this feature be re-implemented + solely by looking at the unit tests?" + - Unit tests for UI components should cover their behavior. Appearance does + not need to be unit tested. + - Unit tests for UI components should always include a baseline accessibility + test using [jest-axe]. + - Use test components like `` to test resulting state + instead of mocking store dispatches. + - Put tests in a `__tests__` directory, mocks in `__mocks__`. +- Include updates to the documentation if a feature is changing. +- Pass `npm run lint` checks with no warnings or errors. +- Use [prettier] for code formatting. There is a `prettier.config.js` file that + does a little configuration of the tool. + +## Code Practices + +- All data changes should take place through stores defined under `src/store`. + Components should manage as little internal state as possible. +- Conversely, code under `src/store` should never touch UI code directly. The + only way these two should interact is by a component dispatching actions in + the relevant store. +- Components under `src/components` should always take values and objects as + props, as opposed to IDs that they look up in a store. Code in `src/dialogs` + or `src/routes` may interact with stores. + - ✅ `` + - ❌ `` +- Components unders `src/components` should be [controlled] unless absolutely + necessary. This applies to all types of components, not just form fields. + - ✅ `` + - ❌ `` + - ✅ `` + - ❌ `` +- Every React component should be assigned a top-level CSS class that is the + React component name in kebab case. All related CSS rules should use this + class name for scoping. This ensures that components will not overwrite each + other's styles. +- Moving business logic out of React components and into `src/util` is almost + always a good idea. +- Use CSS variables defined in `src/styles` as much as possible. +- Use external libraries instead of reinventing the wheel if possible. +- Add external type definitions to `src/externals.d.ts` as a last resort. + Modules that come with type definitions, or that have DefinitelyTyped types, + are strongly preferred. + +# Contributing Localizations Twine's localization strings are stored in [i18next] JSON format. There are a number of dedicated editors for this format, or you can just use a plain text @@ -16,14 +102,22 @@ To add a new localization or edit an existing one: 1. Clone the application source code using Git. 2. Create a new branch for your work. 3. Create or edit the appropriate file in `public/locales`. The file should be - named after the language code you are localizing for. (Check [the registry](lang-code-registry) to find the appropriate code). + named after the language code you are localizing for. (Check [the + registry](lang-code-registry) to find the appropriate code). 4. If you are creating a new localization, copy the existing `en-US.json` file and replace the English strings there with localized ones in the new file. 5. Commit your changes and create a pull request in GitHub. You should target the `develop` branch with your pull request. Once your PR has been accepted, please join the Twine internationalization -listserv by sending an email to `twine-i18n-join@iftechfoundation.org`. This is a low-traffic listserv that will be used to notify people who have worked on localization on Twine when future versions require localization work, e.g. when new text is added to the application. +listserv by sending an email to `twine-i18n-join@iftechfoundation.org`. This is +a low-traffic listserv that will be used to notify people who have worked on +localization on Twine when future versions require localization work, e.g. when +new text is added to the application. +[jest-axe]: https://www.npmjs.com/package/jest-axe [i18next]: https://www.i18next.com/ -[lang-code-registry]: https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry \ No newline at end of file +[lang-code-registry]: https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry +[prettier]: https://prettier.io +[controlled]: https://reactjs.org/docs/forms.html#controlled-components +[Twine's design goals]: DESIGN_GOALS.md \ No newline at end of file diff --git a/DESIGN_GOALS.md b/DESIGN_GOALS.md new file mode 100644 index 000000000..1be08e0f6 --- /dev/null +++ b/DESIGN_GOALS.md @@ -0,0 +1,119 @@ +# Design Goals + +This documents the design goals (and non-goals) Twine has. They're intended to +guide discussion around feature suggestions and future development. Twine, like +any piece of software, isn't perfect, and so it may not entirely live up to the +goals stated here. + +Each of the goals has a set of bullet points that discuss their implications in +practice, but they shouldn't be considered a complete list. + +## Easy to Learn + +It takes around 5-10 minutes to explain how to use Twine to make a story with +basic links. This simplicity is key to Twine's success. A core part of Twine's +audience are people who have had no previous programming experience and may not +even be particularly knowledgable about computers. + +- Twine avoids features that are complicated to explain to a new user. These + features are often better-suited to tools aimed at more advanced users, like + [extwee] and [tweego]. Twine doesn't exclude use by advanced users, but it + prioritizes beginners. +- Twine prefers providing users with sensible defaults that can be changed later + instead of blocking actions and asking for decisions. For example, creating a + new passage creates it with a placeholder name instead of showing a modal + dialog asking the user to provide a name, when the user may not know what they + want to call it yet. +- Twine avoids using [modes], allowing users to work on multiple tasks in + parallel, or to start a task and come back to it later. +- Actions in Twine are undoable, allowing users to easily reverse mistakes. +- Twine's user interface explains itself to users, and strives to be + discoverable. + - It re-uses interface patterns users are likely to already be familiar with + when possible. + - It includes explanatory links like "What is a story format?" + - It avoids technical jargon. + - Features are not placed in contextual menus or available through keyboard + shortcuts only, where only a user who has read documentation might know about + them. + - It uses icon-only buttons extremely sparingly. The intent of a button + labeled with text is almost always clearer than one that only has an icon. +- [The Twine Reference][twine-ref] comprehensively covers Twine's features. + (It's not a tutorial or how-to guide to writing with Twine, though--other + community resources serve that purpose.) + +## Accessible + +There are several dimensions in which Twine strives to be accessible. The +dimensions listed here are equally important. + +Twine is accessible to users with disabilities. + +- User interface development is guided by [WCAG guidelines]. In particular: + - Twine is usable for users who use screen readers like JAWS, NVDA, or + VoiceOver. + - As much of Twine as possible is usable by a someone who only uses a + keyboard. Many users only use a keyboard or assistive technology that + emulates a keyboard. +- Twine is covered by unit tests that check for basic accessibility problems. + These unit tests are not comprehensive but serve as a baseline. + +Twine is accessible to users regardless of the language they speak. + +- No language or locale receives preferential treatment by Twine. + - Twine tries to detect the language the user's computer is set to, instead of + defaulting to US English on first startup. +- All language in Twine is localized. +- Twine properly handles input for users who use right-to-left languages. + +Twine is accessible to users who interact with their computer with touchscreen +input, as well as those who use mouse and keyboard. + +- Interaction targets like buttons and text fields are sized and spaced so that + they can be used comfortably on a touchscreen. +- Interactions triggered by pointing a cursor at something in Twine are avoided, + because these don't have an equivalent on most touchscreens. If they do exist, + alternatives that are usable on touchscreens also exist. + +## Web Native + +Twine's home is the web. Although many dedicated Twine users use it in its +desktop app version (abbreivated here as "app Twine"), there is a significant +population of users who use the online version (abbreviated "browser Twine"). + +It's difficult to estimate an exact number of browser Twine users because, out +of respect for users' privacy, Twine does not include tracking like Google +Analytics. But in one month in 2022, there were more than 100,000 requests for +browser Twine at https://twinery.org/2 in server logs. (This number excludes +known crawlers like Google, as well as requests for all of the assets that the +online editor loads.) A request is more-or-less a single editing session in +browser Twine, so most likely a single person using Twine in a month will +generate multiple requests. Regardless, the point here is that a significant +number of users regularly use browser Twine. + +Staying web native has also meant that adapting Twine for multiple platforms has +been relatively easy thanks to projects like [Electron], and it ensures that +Twine will be usable for years if not decades to come. + +- App Twine and browser Twine users have, as much as possible, identical + experiences. +- App Twine users have identical experiences regardless of what operating system + they use. +- Browser Twine supports as many modern browsers as is feasible, and users of + browser Twine should have identical experiences regardless of which browser + they use. + - The major exception is Safari, which has imposed [restrictions on local + storage](safari-localstorage) which are admirable in their goal of + protecting user privacy, but have dire implications for Twine users, who can + easily lose all of their work if they aren't careful. If it becomes possible + to use browser Twine in Safari safely, it would be worthwhile to make this happen. +- The load time--which loosely equates to the download size--of Twine matters + and new dependencies should be carefully considered before being adopted. + +[extwee]: https://github.com/videlais/extwee +[tweego]: https://www.motoslave.net/tweego/ +[modes]: https://en.wikipedia.org/wiki/Mode_(user_interface) +[wcag guidelines]: https://www.w3.org/WAI/standards-guidelines/wcag/ +[twine-ref]: https://twinery.org/reference/en/ +[electron]: https://www.electronjs.org +[safari-localstorage]: https://webkit.org/blog/10218/full-third-party-cookie-blocking-and-more/ diff --git a/src/routes/story-test/__tests__/story-test-route.test.tsx b/src/routes/story-test/__tests__/story-test-route.test.tsx index b3ee3a951..2959ff8f6 100644 --- a/src/routes/story-test/__tests__/story-test-route.test.tsx +++ b/src/routes/story-test/__tests__/story-test-route.test.tsx @@ -1,4 +1,4 @@ -import {render, screen, waitFor} from '@testing-library/react'; +import {render, waitFor} from '@testing-library/react'; import {createHashHistory} from 'history'; import * as React from 'react'; import {HashRouter, Route} from 'react-router-dom'; From 5b6791ccf99f23498e81bbb19bccdf73b80678fc Mon Sep 17 00:00:00 2001 From: Serhii Mozhaiskyi Date: Sun, 11 Sep 2022 17:32:46 +0300 Subject: [PATCH 05/43] Add ukrainian locale --- public/locales/uk.json | 439 +++++++++++++++++++++++---- src/dialogs/about-twine/credits.json | 1 + src/util/locales.ts | 1 + 3 files changed, 380 insertions(+), 61 deletions(-) diff --git a/public/locales/uk.json b/public/locales/uk.json index cce00cb19..3a885b1e4 100644 --- a/public/locales/uk.json +++ b/public/locales/uk.json @@ -1,107 +1,424 @@ { - "colors": {}, + "colors": { + "none": "Немає", + "red": "Червоний", + "orange": "Помаранчевий", + "yellow": "Жовтий", + "green": "Зелений", + "blue": "Синій", + "purple": "Фіолетовий" + }, "common": { "add": "Додати", "appName": "Twine", + "back": "Назад", + "build": "Запуск", "cancel": "Скасувати", + "close": "Закрити", + "color": "Колір", + "create": "Створити", + "custom": "Custom", "delete": "Видалити", + "deleteCount": "Видалити ({{count}})", + "details": "Деталі", "duplicate": "Дублювати", - "edit": "Редаґувати", + "edit": "Редагувати", + "editCount": "Редагувати ({{count}})", + "help": "Допомога", + "import": "Імпорт", + "maximize": "На весь екран", + "more": "Більше", + "new": "Створити", + "next": "Далі", "ok": "OK", + "passage": "Параграф", "play": "Відтворити", + "preferences": "Налаштування", + "publishToFile": "Публікувати у файл", + "redo": "Повторити", + "redoChange": "Повторити {{change}}", "rename": "Перейменувати", - "remove": "Усунути", + "renamePrompt": "Яка нова назва для “{{name}}”?", + "remove": "Видалити", + "selectAll": "Обрати всі", "skip": "Пропустити", - "storyFormat": "Формат розповіді", + "story": "Оповідання", + "storyFormat": "Формат оповідання", "tag": "Мітка", - "test": "Тестувати", - "undo": "Відмінити" + "test": "Тест", + "twine": "Twine", + "undo": "Скасувати", + "undoChange": "Скасувати {{change}}", + "unmaximize": "Відновити розмір", + "view": "Вигляд" }, "components": { - "addStoryFormatButton": {}, - "addTagButton": {}, - "fontSelect": {"fonts": {}}, - "indentButtons": {}, - "localStorageQuota": {}, + "addTagButton": { + "alreadyAdded": "Така мітка вже існує.", + "addLabel": "Додати мітку", + "invalidName": "Введіть коректну назву мітки.", + "newTag": "Нова мітка", + "tagColorLabel": "Колір мітки", + "tagNameLabel": "Назва мітки" + }, + "dialogCard": { + "contentsCrashed": "Щось пішло не так. Спробуйте закрити це діалогове вікно та відкрити його знову." + }, + "fontSelect": { + "customScaleDetail": "Введіть тільки проценти.", + "customFamilyDetail": "Введіть тільки назву шрифту.", + "familyEmpty": "Введіть назву шрифту.", + "font": "Шрифт", + "fonts": { + "monospaced": "Моноширинний", + "serif": "Із засічками (serif)", + "system": "Системний" + }, + "fontSize": "Розмір шрифту", + "percentage": "{{percent}}%", + "percentageIsntNumber": "Введіть число.", + "percentageNotPositive": "Введіть число більше за 0." + }, + "indentButtons": { + "indent": "Збільшити відступ", + "unindent": "Зменшити відступ" + }, + "localStorageQuota": { + "measureAgain": "Перевірити об'єм доступного місця", + "percentAvailable": "{percent}% місця доступно" + }, "passageCard": { - "placeholderClick": "Клацніть двічі на уривок для редаґування.", - "placeholderTouch": "Натисність на цей уривок, а потім на олівець, аби почати редаґувати." - }, - "renamePassageButton": {"emptyName": "Будь ласка, введіть назву."}, - "renameStoryButton": {"emptyName": "Будь ласка, введіть назву."}, - "safariWarningCard": {}, - "storyCard": {}, - "storyFormatCard": {}, - "storyFormatSelect": {}, - "tagEditor": {} + "placeholderClick": "Клацніть двічі цей параграф, щоб редагувати його.", + "placeholderTouch": "Натисніть на цей параграф, потім оберіть \"Редагувати\" з вкладки \"Параграфи\", щоб редагувати його." + }, + "renamePassageButton": { + "emptyName": "Введіть назву.", + "nameAlreadyUsed": "Інший параграф в оповіданні вже має таку назву." + }, + "renameStoryButton": { + "emptyName": "Введіть назву.", + "nameAlreadyUsed": "Інше оповідання вже має таку назву." + }, + "safariWarningCard": { + "archiveAndUseAnotherBrowser": "Архівуйте ваші оповідання та використовуйте інший браузер.", + "addToHomeScreen": "Додайте цей сайт до вашого домашнього екрану, щоб зняти це обмеження.", + "howToAddToHomeScreen": "Як додати сайт на домашній екран?", + "learnMore": "Дізнатися більше", + "message": "Ваш браузер видалить всі ваші оповідання, якщо ви не відвідували цей сайт протягом семи днів." + }, + "storageQuota": { + "freeSpace": "{{percent}}% місця доступно" + }, + "storyCard": { + "lastUpdated": "Дата редагування: {{date}}", + "passageCount": "1 параграф", + "passageCount_1": "{{count}} параграфи", + "passageCount_2": "{{count}} параграфів" + }, + "storyFormatCard": { + "author": "Автор: {{author}}", + "builtIn": "Вбудований", + "defaultFormat": "Використовується за замовчуванням", + "editorExtensionsDisabled": "Розширення редактора вимкнено", + "license": "Ліцензія: {{license}}", + "loadingFormat": "Завантаження формату оповідання...", + "loadError": "Цей формат оповідання не вийшло завантажити: ({{errorMessage}}).", + "name": "{{name}} {{version}}", + "proofing": "Вичитка", + "proofingFormat": "Використовується для вичитки", + "useEditorExtensions": "Використовувати розширення редактора", + "useFormat": "Використовувати як формат оповідання за замовчуванням", + "useProofingFormat": "Використовувати як формат для вичитки" + }, + "storyFormatSelect": { + "loadingCount": "Завантажується формат оповідання...", + "loadingCount_1": "Завантажуються {{loadingCount}} формати оповідання...", + "loadingCount_2": "Завантажується {{loadingCount}} форматів оповідання..." + }, + "tagEditor": { + "alreadyExists": "Мітка з такою назвою вже існує." + } }, "dialogs": { "aboutTwine": { - "donateToTwine": "Ваш внесок допоможе Twine’ові розвиватися" + "donateToTwine": "Допомогти розвитку Twine пожертвою", + "codeHeader": "Код", + "codeRepo": "Репозиторій вихідного коду", + "license": "Цей додаток випущено за ліцензією GPL v3, проте будь-які роботи, створені за його допомогою, можуть бути випущені на будь-яких умовах, включаючи комерційні.", + "localizationHeader": "Локалізації", + "title": "Про Twine {{version}}", + "twineDescription": "Twine - це інструмент з відкритим кодом для створення інтерактивних нелінійних оповідань." + }, + "appDonation": { + "donate": "Пожертвувати на розробку Twine", + "onlyOnce": "(Це повідомлення буде показано вам лише один раз. Якщо ви захочете пожертвувати на розробку Twine пізніше, перейдіть за посиланням у вікні “Про Twine”.)", + "supportMessage": "Якщо вам подобається Twine, можливо, ви захочете допомогти його розвитку пожертвою. Twine - це проект з відкритим кодом, який завжди буде безкоштовним, а з вашою допомогою Twine продовжить розвиватися.", + "noThanks": "Ні, дякую", + "title": "Підтримати розробку Twine" + }, + "appPrefs": { + "codeEditorFont": "Шрифт редактора", + "codeEditorFontScale": "Розмір шрифту редактора", + "dialogWidth": "Ширина діалогу", + "dialogWidths": { + "default": "Стандартна", + "wider": "Ширша", + "widest": "Найширша" + }, + "editorCursorBlinks": "Блимаючий курсор в редакторі", + "fontExplanation": "Ці шрифти використовуються лише в редакторі Twine. Вони не впливають на шрифт, який використовується під час відтворення оповідання.", + "language": "Мова", + "passageEditorFont": "Шрифт редактора параграфів", + "passageEditorFontScale": "Розмір шрифта редактора параграфів", + "themeLight": "Світла", + "themeDark": "Темна", + "themeSystem": "Системна", + "theme": "Тема оформлення", + "title": "Налаштування" + }, + "passageEdit": { + "editorCrashed": "Щось не так з редактором. Спробуйте його закрити та відкрити знову.", + "passageTextEditorLabel": "Текст параграфа", + "passageTextPlaceholder": "Введіть текст параграфа тут. Щоб створити посилання на параграф, візьміть його назву у дві квадратні дужки, [[ось так]].", + "setAsStart": "Почати оповідання тут", + "size": "Розмір", + "sizeLarge": "Великий", + "sizeSmall": "Маленький", + "sizeTall": "Високий", + "sizeWide": "Широкий" + }, + "passageTags": { + "noTags": "У параграфів в цьому оповіданні немає міток.", + "title": "Мітки параграфів" + }, + "storyImport": { + "deselectAll": "Зняти виділення", + "filePrompt": "Ви можете імпортувати оповідання в Twine з файлу архіву або опублікованого оповідання.", + "importDifferentFile": "Імпортувати інший файл", + "importSelected": "Імпортувати обрані файли", + "importThisStory": "Імпортувати це оповідання", + "noStoriesInFile": "Цей файл не є оповіданням Twine. Оберіть інший файл.", + "storiesPrompt": "Оберіть оповідання для імпорту:", + "title": "Імпорт оповідань", + "willReplaceExisting": "Оповідання з такою ж назвою в вашій бібліотеці буде замінене." + }, + "storyDetails": { + "storyFormatExplanation": "Що таке формат оповідання?", + "snapToGrid": "Прив'язка до сітки", + "stats": { + "brokenLinks": "зламаних посилань", + "characters": "символів", + "title": "Статистика оповідання", + "ifid": "IFID цього оповідання: {{ifid}}.", + "ifidExplanation": "Що таке IFID?", + "lastUpdate": "Дата та час останнього редагування: {{date}}.", + "links": "посилань", + "passages": "параграфів", + "words": "слів" + } }, - "appDonation": {"noThanks": "Ні, дякую"}, - "appPrefs": {"language": "Мова"}, - "passageEdit": {}, - "passageTags": {}, - "storyInfo": {"stats": {"title": "Статистика розповіді"}}, "storyJavaScript": { - "explanation": "Будь-який введений сюди JavaScript негайно розпочне діяти, як тільки Ваша розповідь буде відкрита у вебоглядачеві." + "editorLabel": "JavaScript-код оповідання", + "title": "JavaScript-код оповідання", + "explanation": "Цей код JavaScript буде негайно виконаний, коли це оповідання буде відкрито в браузері." + }, + "storySearch": { + "title": "Знайти і замінити", + "find": "Знайти", + "includePassageNames": "Включати назви параграфів", + "matchCase": "Співпадіння за регістром", + "matchCount": "Знайдено {{count}} параграф", + "matchCount_1": "Знайдено {{count}} параграфи", + "matchCount_2": "Знайдено {{count}} параграфів", + "noMatches": "Немає підходящих параграфів", + "replaceAll": "Замінити у всіх параграфах", + "replaceWith": "Замінити на", + "useRegexes": "Використовувати регулярні вирази" }, - "storySearch": {"title": "Знайти й Замінити", "replaceWith": "Замінити"}, "storyStylesheet": { - "explanation": "Будь-які введені сюди CSS змінять вигляд-за-замовчуванням Вашої розповіді." + "editorLabel": "Таблиця стилів оповідання", + "title": "Таблиця стилів оповідання", + "explanation": "Стилі в цій CSS-таблиці перевизначають стилі оповідання за замовчуванням, змінюючи його вигляд." }, - "storyTags": {} + "storyTags": { + "noTags": "Немає оповідань з мітками.", + "title": "Мітки оповідань" + } }, "electron": { - "errors": {"storyFileChangedExternally": {}}, - "menuBar": {"edit": "Редаґувати"}, - "storiesDirectoryName": "Розповіді" + "backupsDirectoryName": "Backups", + "errors": { + "jsonSave": "Щось пішло не так під час збереження файлу налаштувань.", + "storyFileChangedExternally": { + "message": "Файл “{{fileName}}” з вашої бібліотеки оповідань було змінено за межами Twine.", + "detail": "Збереження змін перезапише цей файл. Якщо ви хочете використовувати цей файл замість того, що є в Twine, Twine буде перезавантажено і ваші зміни будуть знищені.", + "overwriteChoice": "Зберегти зміни з Twine", + "relaunchChoice": "Завантажити файл та перезапустити" + }, + "storyDelete": "Щось пішло не так під час видалення оповідання.", + "storyRename": "Щось пішло не так під час зміни назви оповідання.", + "storySave": "Щось пішло не так під час збереження оповідання." + }, + "menuBar": { + "checkForUpdates": "Перевірити оновлення...", + "edit": "Редагувати", + "showDevTools": "Показати консоль відлагодження", + "showStoryLibrary": "Показати бібліотеку оповідань", + "speech": "Speech", + "troubleshooting": "Вирішення проблем", + "twineHelp": "Допомога по Twine", + "view": "Вигляд" + }, + "storiesDirectoryName": "Оповідання", + "updateCheck": { + "download": "Завантажити", + "error": "Щось пішло не так під час перевірки оновлень Twine.", + "updateAvailable": "Доступна новіша версія Twine.", + "upToDate": "Це остання версія Twine." + } }, "routes": { "storyEdit": { + "toolbar": { + "findAndReplace": "Знайти та замінити", + "javaScript": "JavaScript", + "passageTags": "Мітки параграфів", + "snapToGrid": "Прив'язка до сітки", + "startStoryHere": "Почати оповідання тут", + "stylesheet": "Таблиця стилів", + "testFromHere": "Почати перевірку тут" + }, "topBar": { - "addPassage": "Уривок", - "editJavaScript": "Редаґувати JavaScript розповіді", - "editStylesheet": "Редаґувати стильову таблицю розповіді", - "findAndReplace": "Знайти й Замінити", - "proofStory": "Дивитися Коректурну копію", - "publishToFile": "Опублікувати як файл" + "editJavaScript": "Редагувати JavaScript оповідання", + "editStylesheet": "Редагувати таблицю стилів оповідання", + "findAndReplace": "Знайти та замінити", + "passageTags": "Редагувати мітки параграфів", + "proofStory": "Вичитка", + "publishToFile": "Публікувати у файл", + "selectAllPassages": "Виділити всі параграфи" + }, + "zoomButtons": { + "storyStructure": "Показати лише структуру оповідання", + "passageNames": "Показати лише назви параграфів", + "passageNamesAndExcerpts": "Показати назви параграфів та уривки" } }, "storyFormatList": { - "title": {}, - "storyFormatExplanation": "Формати розповідей визначають їхній вигляд і поведінку під час відтворення." + "noneVisible": "Немає форматів оповідання, що підходять під обрані вами критерії.", + "show": "Показати...", + "title": { + "all": "Всі формати оповідань", + "current": "Формати цього оповідання", + "user": "Формати, додані користувачами" + }, + "toolbar": { + "addStoryFormatButton": { + "addPreview": "Буде додано формат {{storyFormatName}} {{storyFormatVersion}}.", + "alreadyAdded": "{{storyFormatName}} {{storyFormatVersion}} вже було додано.", + "fetchError": "Не вийшло завантажити формат оповідання за цією адресою ({{errorMessage}}).", + "invalidUrl": "Введіть коректний URL.", + "prompt": "Щоб додати формат оповідання, введіть його адресу." + }, + "disableFormatExtensions": "Вимкнути розширення редактора", + "enableFormatExtensions": "Ввімкнути розширення редактора", + "useAsDefaultFormat": "Використовувати як формат за замовчуванням", + "useAsProofingFormat": "Використовувати для вичитки" + }, + "storyFormatExplanation": "Формат оповідання контролює вигляд та поведінку оповідань під час відтворення." }, - "storyImport": {}, "storyList": { - "noStories": "Поки Twine не має жодних збережених розповідей. Аби розпочати, створіть нову розповідь чи зімпортуйте вже існуючу з файлу.", - "titleGeneric": "Розповіді", - "topBar": { - "about": "Про Twine", + "library": "Бібліотека", + "noStories": "Зараз в Twine немає оповідань. Ви можете створити нове оповідання або імпортувати вже існуюче з файлу.", + "taggedTitleCount": "1 оповідання з мітками", + "taggedTitleCount_0": "Немає оповідань з мітками", + "taggedTitleCount_1": "{{count}} оповідання з мітками", + "taggedTitleCount_2": "{{count}} оповідань з мітками", + "titleCount": "1 оповідання", + "titleCount_0": "Немає оповідань", + "titleCount_1": "{{count}} оповідання", + "titleCount_2": "{{count}} оповідань", + "titleGeneric": "Оповідання", + "toolbar": { "archive": "Архів", - "createStory": "Розповідь", - "help": "Допомога", - "sortName": "Назва", - "storyFormats": "Формати" + "createStoryButton": { + "prompt": "Яка назва буде у вашого оповідання? Ви можете змінити її пізніше.", + "emptyName": "Введіть назву.", + "nameConflict": "Вже є оповідання з такою назвою." + }, + "deleteStoryButton": { + "warning": { + "electron": "Ви впевнені, що хочете видалити “{{storyName}}”? Воно буде переміщено у корзину.", + "web": "Ви впевнені, що хочете видалити “{{storyName}}”? Воно буде видалено назавжди. Цю дію не можна буде скасувати." + } + }, + "showAllStories": "Показати всі оповідання", + "showTags": "Показати мітки", + "sort": "Сортування", + "sortByDate": "Дата оновлення", + "sortByName": "Назва", + "storyTags": "Мітки" } }, "welcome": { - "autosaveTitle": "Вашу роботу автоматично збережено.", - "doneTitle": "Ось і все!", - "gotoStoryList": "Перейти до Списку розповідей", + "autosave": "

В папці ваших документів тепер є папка “Twine”. Всередині є папка “Stories”, де буде зберігатися вся ваша праця. Twine зберігає все автоматично під час роботи, тому вам не треба хвилюватися про це. Ви можете відкрити папку з вашими оповіданнями за допомогою пункту “Показати бібліотеку” в меню “Twine”.

Оскільки Twine постійно зберігає вашу працю, файли в вашій бібліотеці оповідань будуть заблоковані для редагування, поки Twine відкритий.

Якщо ви хочете відкрити файл оповідання Twine, який отримали від когось іншого, ви можете імпортувати його в вашу бібліотеку за допомогою функції “Імпорт” в переліку оповідань.

", + "autosaveTitle": "Ваша праця автоматично зберігається.", + "browserStorage": "

Вам не потрібно створювати обліковий запис, щоб використовувати Twine 2. Все, що ви створюєте, зберігається не на якомусь сервері, а залишається в вашому браузері.

Зверніть увагу на дві дуже важливі речі. По-перше, оскільки ваша робота зберігається лише в вашому браузері, ви втратите її, якщо видалите з браузеру збережені дані! Погано. Тому використовуйте функцію “Архів” частіше. Також ви можете публікувати окремі оповідання в файл, використовуючи меню в списку оповідань. Архіви та файлі оповідань завжди можна знову імпортувати в Twine.

По-друге, будь-який користувач цього браузеру може бачити та змінювати вашу роботу. Якщо у вас є допитливий молодший брат, краще створіть окремий профіль для себе.

", + "browserStorageTitle": "Ваша праця зберігається лише в вашому браузері", + "done": "

Дякуємо за читання. Гарно провести час з Twine!

", + "doneTitle": "Це все!", + "gotoStoryList": "Перейти до списку оповідань", + "greeting": "

Twine - це інструмент з відкритим кодом для створення інтерактивних нелінійних оповідань. Є декілька речей, які потрібно знати перед тим, як почати.

", "greetingTitle": "Привіт!", - "tellMeMore": "Розкажіть мені більше", - "helpTitle": "Потрібна допомога?" + "tellMeMore": "Дізнатися більше", + "help": "

Якщо ви не використовували Twine раніше, ласкаво просимо! Посібник Twine (англійською) допоможе навчитися ним користуватися. Якщо ви не користувалися Twine раніше, радимо почати з нього.

", + "helpTitle": "Новенький?" + } + }, + "routeActions": { + "app": { + "aboutApp": "Про Twine", + "preferences": "Налаштування", + "reportBug": "Сповістити про помилку", + "storyFormats": "Формати оповідання" + }, + "build": { + "play": "Відтворити", + "proof": "Вичитати", + "publishToFile": "Публікувати у файл", + "test": "Тестувати" } }, "store": { - "errors": {}, + "archiveFilename": "{{timestamp}} Twine Archive.html", + "errors": { + "cantPersistPrefs": "Щось пішло не так під час збереження налаштувань ({{error}}).", + "cantPersistStories": "Щось пішло не так під час збереження оповідань ({{error}}).", + "cantPersistStoryFormats": "Щось пішло не так під час збереження форматів оповідання ({{error}}).", + "electronRemediation": "Перезапуск цього додатку може допомогти.", + "webRemediation": "Перезавантаження цієї сторінки може допомогти." + }, "passageDefaults": { - "name": "Уривок без назви" + "name": "Параграф без назви" }, - "storyDefaults": {"name": "Розповідь без назви"}, - "storyFormatDefaults": {"name": "Формат розповіді без назви"} + "storyDefaults": { + "name": "Оповідання без назви" + }, + "storyFormatDefaults": { + "name": "Формат оповідання без назви" + } }, - "undoChange": {"replaceAllText": "Замінити все"} + "undoChange": { + "addTag": "додавання мітки", + "changeTagColor": "зміну кольору мітки", + "newPassage": "створення параграфа", + "deletePassage": "видалення параграфа", + "deletePassages": "видалення параграфів", + "movePassage": "переміщення параграфа", + "movePassages": "переміщення параграфів", + "imortTag": "видалення мітки", + "renamePassage": "перейменування параграфа", + "removeTag": "видалення мітки", + "renameTag": "перейменування мітки", + "replaceAllText": "заміну тексту" + } } diff --git a/src/dialogs/about-twine/credits.json b/src/dialogs/about-twine/credits.json index bc74a4a8e..b812cf5a7 100644 --- a/src/dialogs/about-twine/credits.json +++ b/src/dialogs/about-twine/credits.json @@ -25,6 +25,7 @@ "Mika Letonsaari (Suomi)", "Désirée Nordlund (Svenska)", "H. Utku Maden (Türkçe)", + "Serhii Mozhaiskyi (Українська)", "Shitake & SEN1 (Chinese)" ] } diff --git a/src/util/locales.ts b/src/util/locales.ts index c295559fa..bf1dcb5ed 100644 --- a/src/util/locales.ts +++ b/src/util/locales.ts @@ -20,6 +20,7 @@ export const locales = [ {code: 'ru', name: 'русский'}, {code: 'sv', name: 'Svenska'}, {code: 'tr', name: 'Türkçe'}, + {code: 'uk', name: 'Українська'}, {code: 'zh-cn', name: '中文(简体)'} ]; From 0a09aab65fe382d7fa2e8e65fbb758a19a490fec Mon Sep 17 00:00:00 2001 From: Serhii Mozhaiskyi Date: Sun, 11 Sep 2022 18:18:16 +0300 Subject: [PATCH 06/43] undo 'credits' modification --- src/dialogs/about-twine/credits.json | 1 - 1 file changed, 1 deletion(-) diff --git a/src/dialogs/about-twine/credits.json b/src/dialogs/about-twine/credits.json index b812cf5a7..bc74a4a8e 100644 --- a/src/dialogs/about-twine/credits.json +++ b/src/dialogs/about-twine/credits.json @@ -25,7 +25,6 @@ "Mika Letonsaari (Suomi)", "Désirée Nordlund (Svenska)", "H. Utku Maden (Türkçe)", - "Serhii Mozhaiskyi (Українська)", "Shitake & SEN1 (Chinese)" ] } From eac15be0f49ddc49bcf70a6bd3e77c8362e2901c Mon Sep 17 00:00:00 2001 From: Serhii Mozhaiskyi Date: Sun, 11 Sep 2022 18:20:42 +0300 Subject: [PATCH 07/43] fix locale --- public/locales/uk.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/public/locales/uk.json b/public/locales/uk.json index 3a885b1e4..5a953b1aa 100644 --- a/public/locales/uk.json +++ b/public/locales/uk.json @@ -141,7 +141,7 @@ }, "dialogs": { "aboutTwine": { - "donateToTwine": "Допомогти розвитку Twine пожертвою", + "donateToTwine": "Зробити внесок на розвиток Twine", "codeHeader": "Код", "codeRepo": "Репозиторій вихідного коду", "license": "Цей додаток випущено за ліцензією GPL v3, проте будь-які роботи, створені за його допомогою, можуть бути випущені на будь-яких умовах, включаючи комерційні.", @@ -150,9 +150,9 @@ "twineDescription": "Twine - це інструмент з відкритим кодом для створення інтерактивних нелінійних оповідань." }, "appDonation": { - "donate": "Пожертвувати на розробку Twine", - "onlyOnce": "(Це повідомлення буде показано вам лише один раз. Якщо ви захочете пожертвувати на розробку Twine пізніше, перейдіть за посиланням у вікні “Про Twine”.)", - "supportMessage": "Якщо вам подобається Twine, можливо, ви захочете допомогти його розвитку пожертвою. Twine - це проект з відкритим кодом, який завжди буде безкоштовним, а з вашою допомогою Twine продовжить розвиватися.", + "donate": "Зробити внесок на розробку Twine", + "onlyOnce": "(Це повідомлення буде показано вам лише один раз. Якщо ви захочете зробити внесок на розробку Twine пізніше, перейдіть за посиланням у вікні “Про Twine”.)", + "supportMessage": "Якщо вам подобається Twine, можливо, ви захочете допомогти його розвитку фінансовим внеском. Twine - це проект з відкритим кодом, який завжди буде безкоштовним, а з вашою допомогою Twine продовжить розвиватися.", "noThanks": "Ні, дякую", "title": "Підтримати розробку Twine" }, From 9763019a6bb7f4f90c602217732ce71b301d4bb9 Mon Sep 17 00:00:00 2001 From: Serhii Mozhaiskyi Date: Mon, 12 Sep 2022 21:47:19 +0300 Subject: [PATCH 08/43] minor update --- public/locales/uk.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/locales/uk.json b/public/locales/uk.json index 5a953b1aa..4af45cfa2 100644 --- a/public/locales/uk.json +++ b/public/locales/uk.json @@ -89,7 +89,7 @@ }, "passageCard": { "placeholderClick": "Клацніть двічі цей параграф, щоб редагувати його.", - "placeholderTouch": "Натисніть на цей параграф, потім оберіть \"Редагувати\" з вкладки \"Параграфи\", щоб редагувати його." + "placeholderTouch": "Натисніть на цей параграф, потім оберіть “Редагувати” з вкладки “Параграфи”, щоб редагувати його." }, "renamePassageButton": { "emptyName": "Введіть назву.", @@ -152,7 +152,7 @@ "appDonation": { "donate": "Зробити внесок на розробку Twine", "onlyOnce": "(Це повідомлення буде показано вам лише один раз. Якщо ви захочете зробити внесок на розробку Twine пізніше, перейдіть за посиланням у вікні “Про Twine”.)", - "supportMessage": "Якщо вам подобається Twine, можливо, ви захочете допомогти його розвитку фінансовим внеском. Twine - це проект з відкритим кодом, який завжди буде безкоштовним, а з вашою допомогою Twine продовжить розвиватися.", + "supportMessage": "Якщо вам подобається Twine, можливо, ви захочете допомогти його розвитку фінансовим внеском. Twine - це проєкт з відкритим кодом, який завжди буде безкоштовним, а з вашою допомогою Twine продовжить розвиватися.", "noThanks": "Ні, дякую", "title": "Підтримати розробку Twine" }, From 3433639a7454aab0787afa92ef8eb11c6fb3ae8f Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Sun, 18 Sep 2022 13:37:14 -0400 Subject: [PATCH 09/43] Highlight a dialog if the user asks to re-open it --- .../__tests__/dialog-card.test.tsx | 14 +++ .../container/dialog-card/dialog-card.css | 5 + .../container/dialog-card/dialog-card.tsx | 13 +++ .../context/__tests__/dialogs.test.tsx | 33 +++++- src/dialogs/context/__tests__/reducer.test.ts | 100 +++++++++++++++++- src/dialogs/context/dialogs.tsx | 3 + src/dialogs/context/reducer.ts | 17 ++- src/dialogs/dialogs.types.ts | 6 ++ src/styles/animations.css | 22 ++++ 9 files changed, 203 insertions(+), 10 deletions(-) diff --git a/src/components/container/dialog-card/__tests__/dialog-card.test.tsx b/src/components/container/dialog-card/__tests__/dialog-card.test.tsx index f9f44c4d1..e249398d2 100644 --- a/src/components/container/dialog-card/__tests__/dialog-card.test.tsx +++ b/src/components/container/dialog-card/__tests__/dialog-card.test.tsx @@ -53,6 +53,20 @@ describe('', () => { expect(screen.queryByLabelText('common.maximize')).not.toBeInTheDocument(); }); + it('adds a CSS class when highlighted', () => { + renderComponent({highlighted: true}); + expect( + document.querySelector('.dialog-card')?.classList.contains('highlighted') + ).toBe(true); + }); + + it("doesn't add a CSS class when unhighlighted", () => { + renderComponent({highlighted: false}); + expect( + document.querySelector('.dialog-card')?.classList.contains('highlighted') + ).toBe(false); + }); + it('adds a CSS class when maximized', () => { renderComponent({maximized: true}); expect( diff --git a/src/components/container/dialog-card/dialog-card.css b/src/components/container/dialog-card/dialog-card.css index 9c2106785..5565f2724 100644 --- a/src/components/container/dialog-card/dialog-card.css +++ b/src/components/container/dialog-card/dialog-card.css @@ -1,3 +1,4 @@ +@import '../../../styles/animations.css'; @import '../../../styles/typography.css'; @import '../../../styles/metrics.css'; @@ -6,6 +7,10 @@ width: 100%; } +.dialog-card.highlighted { + animation: 0.4s wiggle linear; +} + .dialog-card .card { padding: 0; } diff --git a/src/components/container/dialog-card/dialog-card.tsx b/src/components/container/dialog-card/dialog-card.tsx index 6163139f0..653499fe3 100644 --- a/src/components/container/dialog-card/dialog-card.tsx +++ b/src/components/container/dialog-card/dialog-card.tsx @@ -19,9 +19,11 @@ export interface DialogCardProps { collapsed: boolean; fixedSize?: boolean; headerLabel: string; + highlighted?: boolean; maximizable?: boolean; maximized?: boolean; onChangeCollapsed: (value: boolean) => void; + onChangeHighlighted: (value: boolean) => void; onChangeMaximized: (value: boolean) => void; onClose: () => void; } @@ -33,9 +35,11 @@ export const DialogCard: React.FC = props => { collapsed, fixedSize, headerLabel, + highlighted, maximizable, maximized, onChangeCollapsed, + onChangeHighlighted, onChangeMaximized, onClose } = props; @@ -48,8 +52,17 @@ export const DialogCard: React.FC = props => { } }, [error]); + React.useEffect(() => { + if (highlighted) { + const timeout = window.setTimeout(() => onChangeHighlighted(false), 400); + + return () => window.clearTimeout(timeout); + } + }, [highlighted, onChangeHighlighted]); + const calcdClassName = classNames('dialog-card', className, { collapsed, + highlighted, 'fixed-size': fixedSize, maximized }); diff --git a/src/dialogs/context/__tests__/dialogs.test.tsx b/src/dialogs/context/__tests__/dialogs.test.tsx index fdfc6e4c6..4fb0b34b5 100644 --- a/src/dialogs/context/__tests__/dialogs.test.tsx +++ b/src/dialogs/context/__tests__/dialogs.test.tsx @@ -6,14 +6,15 @@ import {FakeStateProvider} from '../../../test-util'; import {Dialogs} from '../dialogs'; import {DialogsContext, DialogsContextProps} from '../dialogs-context'; -const MockComponent: React.FC<{collapsed?: boolean; maximized?: boolean}> = ({ - children, - collapsed, - maximized -}) => ( +const MockComponent: React.FC<{ + collapsed?: boolean; + highlighted?: boolean; + maximized?: boolean; +}> = ({children, collapsed, highlighted, maximized}) => (
{children} @@ -42,12 +43,14 @@ describe('', () => { { collapsed: false, component: MockComponent, + highlighted: false, maximized: false, props: {children: 'mock child 1'} }, { collapsed: false, component: MockComponent, + highlighted: false, maximized: false, props: {children: 'mock child 2'} } @@ -64,6 +67,7 @@ describe('', () => { { collapsed: true, component: MockComponent, + highlighted: false, maximized: false, props: {children: 'mock child 1'} } @@ -73,12 +77,31 @@ describe('', () => { expect(screen.getByTestId('mock-component').dataset.collapsed).toBe('true'); }); + it('sets the highlighted prop on the dialog component', () => { + renderComponent({ + dialogs: [ + { + collapsed: true, + component: MockComponent, + highlighted: true, + maximized: false, + props: {children: 'mock child 1'} + } + ] + }); + + expect(screen.getByTestId('mock-component').dataset.highlighted).toBe( + 'true' + ); + }); + it('sets the maximized prop on the dialog component', () => { renderComponent({ dialogs: [ { collapsed: true, component: MockComponent, + highlighted: false, maximized: true, props: {children: 'mock child 1'} } diff --git a/src/dialogs/context/__tests__/reducer.test.ts b/src/dialogs/context/__tests__/reducer.test.ts index 927078905..94490391a 100644 --- a/src/dialogs/context/__tests__/reducer.test.ts +++ b/src/dialogs/context/__tests__/reducer.test.ts @@ -15,6 +15,7 @@ describe('Dialog reducer', () => { { collapsed: false, component: mockComponent, + highlighted: false, maximized: false, props: {mockProp: true} } @@ -27,6 +28,7 @@ describe('Dialog reducer', () => { { collapsed: false, component: mockComponent, + highlighted: false, maximized: false, props: {mockProp: true} } @@ -41,24 +43,27 @@ describe('Dialog reducer', () => { { collapsed: false, component: mockComponent, + highlighted: false, maximized: false, props: {mockProp: true} }, { collapsed: false, component: mockComponent, + highlighted: false, maximized: false, props: {mockProp: false} } ])); - it('expands an existing, collapsed dialog if its component and props are identical', () => + it('expands and highlights an existing, collapsed dialog if its component and props are identical', () => expect( reducer( [ { collapsed: true, component: mockComponent, + highlighted: false, maximized: false, props: {mockProp: true} } @@ -73,6 +78,7 @@ describe('Dialog reducer', () => { { collapsed: false, component: mockComponent, + highlighted: true, maximized: false, props: {mockProp: true} } @@ -87,12 +93,14 @@ describe('Dialog reducer', () => { { collapsed: false, component: mockComponent, + highlighted: false, maximized: false, props: {mockProp: true} }, { collapsed: false, component: mockComponent, + highlighted: false, maximized: false, props: {mockProp: false} } @@ -103,6 +111,7 @@ describe('Dialog reducer', () => { { collapsed: false, component: mockComponent, + highlighted: false, maximized: false, props: {mockProp: false} } @@ -113,12 +122,14 @@ describe('Dialog reducer', () => { { collapsed: false, component: mockComponent, + highlighted: false, maximized: false, props: {mockProp: true} }, { collapsed: false, component: mockComponent, + highlighted: false, maximized: false, props: {mockProp: false} } @@ -129,6 +140,7 @@ describe('Dialog reducer', () => { { collapsed: false, component: mockComponent, + highlighted: false, maximized: false, props: {mockProp: true} } @@ -142,6 +154,7 @@ describe('Dialog reducer', () => { { collapsed: false, component: mockComponent, + highlighted: false, maximized: false, props: {mockProp: true} } @@ -152,6 +165,7 @@ describe('Dialog reducer', () => { { collapsed: false, component: mockComponent, + highlighted: false, maximized: false, props: {mockProp: true} } @@ -167,12 +181,14 @@ describe('Dialog reducer', () => { { collapsed: false, component: mockComponent, + highlighted: false, maximized: false, props: {mockProp: true} }, { collapsed: false, component: mockComponent, + highlighted: false, maximized: false, props: {mockProp: false} } @@ -183,12 +199,14 @@ describe('Dialog reducer', () => { { collapsed: true, component: mockComponent, + highlighted: false, maximized: false, props: {mockProp: true} }, { collapsed: false, component: mockComponent, + highlighted: false, maximized: false, props: {mockProp: false} } @@ -201,6 +219,7 @@ describe('Dialog reducer', () => { { collapsed: false, component: mockComponent, + highlighted: false, maximized: false, props: {mockProp: true} } @@ -211,6 +230,71 @@ describe('Dialog reducer', () => { { collapsed: false, component: mockComponent, + highlighted: false, + maximized: false, + props: {mockProp: true} + } + ])); + }); + + describe('when a setDialogHighlighted action is received', () => { + it('updates the dialog at the index specified', () => + expect( + reducer( + [ + { + collapsed: false, + component: mockComponent, + highlighted: false, + maximized: false, + props: {mockProp: true} + }, + { + collapsed: false, + component: mockComponent, + highlighted: false, + maximized: false, + props: {mockProp: false} + } + ], + {type: 'setDialogHighlighted', highlighted: true, index: 0} + ) + ).toEqual([ + { + collapsed: false, + component: mockComponent, + highlighted: true, + maximized: false, + props: {mockProp: true} + }, + { + collapsed: false, + component: mockComponent, + highlighted: false, + maximized: false, + props: {mockProp: false} + } + ])); + + it('does nothing if an incorrect index is specified', () => + expect( + reducer( + [ + { + collapsed: false, + component: mockComponent, + highlighted: false, + maximized: false, + props: {mockProp: true} + } + ], + {type: 'setDialogHighlighted', highlighted: true, index: 2} + ) + ).toEqual([ + { + collapsed: false, + component: mockComponent, + highlighted: false, maximized: false, props: {mockProp: true} } @@ -225,12 +309,14 @@ describe('Dialog reducer', () => { { collapsed: false, component: mockComponent, + highlighted: false, maximized: false, props: {mockProp: true} }, { collapsed: false, component: mockComponent, + highlighted: false, maximized: false, props: {mockProp: false} } @@ -241,12 +327,14 @@ describe('Dialog reducer', () => { { collapsed: false, component: mockComponent, + highlighted: false, maximized: true, props: {mockProp: true} }, { collapsed: false, component: mockComponent, + highlighted: false, maximized: false, props: {mockProp: false} } @@ -257,12 +345,14 @@ describe('Dialog reducer', () => { { collapsed: false, component: mockComponent, + highlighted: false, maximized: true, props: {mockProp: true} }, { collapsed: false, component: mockComponent, + highlighted: false, maximized: false, props: {mockProp: false} } @@ -273,12 +363,14 @@ describe('Dialog reducer', () => { { collapsed: false, component: mockComponent, + highlighted: false, maximized: false, props: {mockProp: true} }, { collapsed: false, component: mockComponent, + highlighted: false, maximized: false, props: {mockProp: false} } @@ -292,12 +384,14 @@ describe('Dialog reducer', () => { { collapsed: false, component: mockComponent, + highlighted: false, maximized: false, props: {mockProp: true} }, { collapsed: false, component: mockComponent, + highlighted: false, maximized: true, props: {mockProp: false} } @@ -308,12 +402,14 @@ describe('Dialog reducer', () => { { collapsed: false, component: mockComponent, + highlighted: false, maximized: true, props: {mockProp: true} }, { collapsed: false, component: mockComponent, + highlighted: false, maximized: false, props: {mockProp: false} } @@ -326,6 +422,7 @@ describe('Dialog reducer', () => { { collapsed: false, component: mockComponent, + highlighted: false, maximized: false, props: {mockProp: true} } @@ -336,6 +433,7 @@ describe('Dialog reducer', () => { { collapsed: false, component: mockComponent, + highlighted: false, maximized: false, props: {mockProp: true} } diff --git a/src/dialogs/context/dialogs.tsx b/src/dialogs/context/dialogs.tsx index 51f60f1b8..cfebb9cc3 100644 --- a/src/dialogs/context/dialogs.tsx +++ b/src/dialogs/context/dialogs.tsx @@ -34,9 +34,12 @@ export const Dialogs: React.FC = () => { {dialogs.map((dialog, index) => { const managementProps = { collapsed: dialog.collapsed, + highlighted: dialog.highlighted, maximized: dialog.maximized, onChangeCollapsed: (collapsed: boolean) => dispatch({type: 'setDialogCollapsed', collapsed, index}), + onChangeHighlighted: (highlighted: boolean) => + dispatch({type: 'setDialogHighlighted', highlighted, index}), onChangeMaximized: (maximized: boolean) => dispatch({type: 'setDialogMaximized', maximized, index}), onClose: () => dispatch({type: 'removeDialog', index}) diff --git a/src/dialogs/context/reducer.ts b/src/dialogs/context/reducer.ts index 98057a8f3..438565cc0 100644 --- a/src/dialogs/context/reducer.ts +++ b/src/dialogs/context/reducer.ts @@ -8,22 +8,23 @@ export const reducer: React.Reducer = ( ) => { switch (action.type) { case 'addDialog': - // If the dialog has been previously added, expand it. Otherwise, add it - // to the end. + // If the dialog has been previously added, expand and/or highlight it. + // Otherwise, add it to the end. let exists = false; const editedState = state.map(stateDialog => { if ( isEqual(stateDialog, { - // Ignore collapsed and maximized properties for comparison. + // Ignore collapsed, highlighted, and maximized properties for comparison. collapsed: stateDialog.collapsed, component: action.component, + highlighted: stateDialog.highlighted, maximized: stateDialog.maximized, props: action.props }) ) { exists = true; - return {...stateDialog, collapsed: false}; + return {...stateDialog, collapsed: false, highlighted: true}; } return stateDialog; @@ -38,6 +39,7 @@ export const reducer: React.Reducer = ( { collapsed: false, component: action.component, + highlighted: false, maximized: false, props: action.props } @@ -53,6 +55,13 @@ export const reducer: React.Reducer = ( : dialog ); + case 'setDialogHighlighted': + return state.map((dialog, index) => + index === action.index + ? {...dialog, highlighted: action.highlighted} + : dialog + ); + case 'setDialogMaximized': return state.map((dialog, index) => ({ ...dialog, diff --git a/src/dialogs/dialogs.types.ts b/src/dialogs/dialogs.types.ts index f27a8f183..13cc52dd7 100644 --- a/src/dialogs/dialogs.types.ts +++ b/src/dialogs/dialogs.types.ts @@ -11,6 +11,11 @@ export interface Dialog { * Component to render. */ component: React.ComponentType; + /** + * Is the dialog highlighted? This is used to call attention to one when the + * user asks to re-open it. + */ + highlighted: boolean; /** * Is the dialog maximized? Although only one dialog can be maximized at a * time, this is an attribute so that when a dialog is un-minimized, it goes @@ -33,4 +38,5 @@ export type DialogsAction = } | {type: 'removeDialog'; index: number} | {type: 'setDialogCollapsed'; collapsed: boolean; index: number} + | {type: 'setDialogHighlighted'; highlighted: boolean; index: number} | {type: 'setDialogMaximized'; maximized: boolean; index: number}; \ No newline at end of file diff --git a/src/styles/animations.css b/src/styles/animations.css index 301642c2f..ae0a7f848 100644 --- a/src/styles/animations.css +++ b/src/styles/animations.css @@ -34,3 +34,25 @@ transform: scale(1); } } + +@keyframes wiggle { + 0% { + transform: rotate(0deg); + } + + 25% { + transform: rotate(-1deg); + } + + 50% { + transform: scale(1.025) rotate(1deg); + } + + 75% { + transform: rotate(-1deg); + } + + 100% { + transform: scale(1) rotate(0deg); + } +} \ No newline at end of file From 5067ecc9ccdd527ea580bc46385ac34721d6877b Mon Sep 17 00:00:00 2001 From: crackevil Date: Tue, 20 Sep 2022 00:46:07 +0800 Subject: [PATCH 10/43] update format repo urls --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2ac60b34d..f7fcbef2b 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,10 @@ This is a port of Twine to a browser and Electron app. See The story formats in minified format under `story-formats/` exist in separate repositories: -- [Harlowe](https://bitbucket.org/_L_/harlowe) +- [Harlowe](https://foss.heptapod.net/games/harlowe/) - [Paperthin](https://github.com/klembot/paperthin) - [Snowman](https://github.com/klembot/snowman) -- [SugarCube](https://bitbucket.org/tmedwards/sugarcube) +- [SugarCube](https://github.com/tmedwards/sugarcube-2) ### INSTALL From bb474710d5b3b3e94cd6dc941368922f3cbb7b68 Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Mon, 26 Sep 2022 22:34:52 -0400 Subject: [PATCH 11/43] Stop main content grabbing if pointer leaves container Resolves #1221 --- src/components/container/main-content.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/container/main-content.tsx b/src/components/container/main-content.tsx index 73b623a0f..1146cb228 100644 --- a/src/components/container/main-content.tsx +++ b/src/components/container/main-content.tsx @@ -44,23 +44,31 @@ export const MainContent = React.forwardRef( } } - function upListener(event: PointerEvent) { - if (event.button !== 2 || !container) { + function stopGrab(event: PointerEvent) { + if (!container) { return; } container.releasePointerCapture(event.pointerId); + container.removeEventListener('pointerleave', stopGrab); container.removeEventListener('pointermove', moveListener); container.style.cursor = ''; event.preventDefault(); } + function upListener(event: PointerEvent) { + if (event.button === 2) { + stopGrab(event); + } + } + function downListener(event: PointerEvent) { if (event.button !== 2 || !container) { return; } container.setPointerCapture(event.pointerId); + container.addEventListener('pointerleave', stopGrab); container.addEventListener('pointermove', moveListener); container.addEventListener('pointerup', upListener); container.style.cursor = 'grabbing'; From b8c9f4c554b07e113a6488ba9110cceb1bcf4a73 Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Sat, 1 Oct 2022 22:18:35 -0400 Subject: [PATCH 12/43] Import stories when there are no conflicts, add test coverage --- .../story-import/__mocks__/file-chooser.tsx | 15 +++ .../story-import/__mocks__/story-chooser.tsx | 16 +++ .../__tests__/file-chooser.test.tsx | 28 ++++ .../__tests__/story-chooser.test.tsx | 94 +++++++++++++ .../__tests__/story-import.test.tsx | 123 ++++++++++++++++++ src/dialogs/story-import/story-chooser.tsx | 23 +++- src/dialogs/story-import/story-import.tsx | 27 +++- 7 files changed, 313 insertions(+), 13 deletions(-) create mode 100644 src/dialogs/story-import/__mocks__/file-chooser.tsx create mode 100644 src/dialogs/story-import/__mocks__/story-chooser.tsx create mode 100644 src/dialogs/story-import/__tests__/file-chooser.test.tsx create mode 100644 src/dialogs/story-import/__tests__/story-chooser.test.tsx create mode 100644 src/dialogs/story-import/__tests__/story-import.test.tsx diff --git a/src/dialogs/story-import/__mocks__/file-chooser.tsx b/src/dialogs/story-import/__mocks__/file-chooser.tsx new file mode 100644 index 000000000..cb76c170d --- /dev/null +++ b/src/dialogs/story-import/__mocks__/file-chooser.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; +import {fakeStory} from '../../../test-util'; +import {FileChooserProps} from '../file-chooser'; + +const mockFile = new File([''], 'mock-file.html'); +const mockStory = fakeStory(); + +mockStory.name = 'mock-story'; + +export const FileChooser: React.FC = ({onChange}) => ( +
+ + +
+); diff --git a/src/dialogs/story-import/__mocks__/story-chooser.tsx b/src/dialogs/story-import/__mocks__/story-chooser.tsx new file mode 100644 index 000000000..13876deea --- /dev/null +++ b/src/dialogs/story-import/__mocks__/story-chooser.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; +import {StoryChooserProps} from '../story-chooser'; + +export const StoryChooser: React.FC = ({ + onImport, + stories +}) => ( +
+
    + {stories.map(story => ( +
  • {story.name}
  • + ))} +
+ +
+); diff --git a/src/dialogs/story-import/__tests__/file-chooser.test.tsx b/src/dialogs/story-import/__tests__/file-chooser.test.tsx new file mode 100644 index 000000000..767fa554c --- /dev/null +++ b/src/dialogs/story-import/__tests__/file-chooser.test.tsx @@ -0,0 +1,28 @@ +import {render, screen} from '@testing-library/react'; +import {axe} from 'jest-axe'; +import {FileChooser, FileChooserProps} from '../file-chooser'; + +describe('FileChooser', () => { + function renderComponent(props?: Partial) { + return render(); + } + + it('displays a file input that accepts only HTML files', () => { + renderComponent(); + + const input = screen.getByLabelText('dialogs.storyImport.filePrompt'); + + expect(input).toBeInTheDocument(); + expect(input).toHaveAttribute('accept', '.html'); + }); + + // Todo for the same reason this test is todo on FileInput under components. + + it.todo('calls the onChange prop when a file is chosen'); + + it('is accessible', async () => { + const {container} = renderComponent(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/src/dialogs/story-import/__tests__/story-chooser.test.tsx b/src/dialogs/story-import/__tests__/story-chooser.test.tsx new file mode 100644 index 000000000..394ec6a55 --- /dev/null +++ b/src/dialogs/story-import/__tests__/story-chooser.test.tsx @@ -0,0 +1,94 @@ +import {fireEvent, render, screen} from '@testing-library/react'; +import {axe} from 'jest-axe'; +import * as React from 'react'; +import {Story} from '../../../store/stories'; +import {fakeStory} from '../../../test-util'; +import {StoryChooser, StoryChooserProps} from '../story-chooser'; + +describe('StoryChooser', () => { + let stories: Story[]; + + function renderComponent(props?: Partial) { + return render( + + ); + } + + beforeEach(() => (stories = [fakeStory(), fakeStory(), fakeStory()])); + + it('renders a checkbox for every story in props', () => { + renderComponent({stories}); + + for (const story of stories) { + expect( + screen.getByRole('checkbox', {name: story.name}) + ).toBeInTheDocument(); + } + }); + + it("checks the checkbox for stories that don't conflict with existing ones", () => { + renderComponent({existingStories: [stories[0], stories[2]], stories}); + expect( + screen.getByRole('checkbox', {name: stories[0].name}) + ).not.toBeChecked(); + expect(screen.getByRole('checkbox', {name: stories[1].name})).toBeChecked(); + expect( + screen.getByRole('checkbox', {name: stories[2].name}) + ).not.toBeChecked(); + }); + + it('enables the import button only if at least one story is selected', () => { + renderComponent({stories, existingStories: stories}); + expect( + screen.getByRole('button', {name: 'dialogs.storyImport.importSelected'}) + ).toBeDisabled(); + expect( + screen.getByRole('button', {name: 'dialogs.storyImport.importSelected'}) + ).toBeDisabled(); + fireEvent.click( + screen + .getByRole('checkbox', {name: stories[0].name}) + .querySelector('button')! + ); + expect( + screen.getByRole('button', {name: 'dialogs.storyImport.importSelected'}) + ).not.toBeDisabled(); + }); + + it('calls onImport with checked stories when the import button is clicked', () => { + const onImport = jest.fn(); + + // Force all but the last checkbox to start unchecked. + renderComponent({ + onImport, + stories, + existingStories: [stories[0], stories[1]] + }); + fireEvent.click( + screen + .getByRole('checkbox', {name: stories[1].name}) + .querySelector('button')! + ); + fireEvent.click( + screen + .getByRole('checkbox', {name: stories[2].name}) + .querySelector('button')! + ); + expect(onImport).not.toHaveBeenCalled(); + fireEvent.click( + screen.getByRole('button', {name: 'dialogs.storyImport.importSelected'}) + ); + expect(onImport.mock.calls).toEqual([[[stories[1]]]]); + }); + + it('is accessible', async () => { + const {container} = renderComponent(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/src/dialogs/story-import/__tests__/story-import.test.tsx b/src/dialogs/story-import/__tests__/story-import.test.tsx new file mode 100644 index 000000000..c9a63df40 --- /dev/null +++ b/src/dialogs/story-import/__tests__/story-import.test.tsx @@ -0,0 +1,123 @@ +import {fireEvent, render, screen, within} from '@testing-library/react'; +import {axe} from 'jest-axe'; +import {Story} from '../../../store/stories'; +import {FakeStateProvider, fakeStory, StoryInspector} from '../../../test-util'; +import {StoryImportDialog, StoryImportDialogProps} from '../story-import'; + +jest.mock('../file-chooser'); +jest.mock('../story-chooser'); + +describe('StoryImportDialog', () => { + function renderComponent( + props?: Partial, + stories?: Story[] + ) { + const story = fakeStory(); + + // Should match the story name in FileChooser's mock. + story.name = 'mock-story'; + + return render( + + + + + ); + } + + describe('initially', () => { + it('shows a file chooser', () => { + renderComponent(); + expect(screen.getByTestId('mock-file-chooser')).toBeInTheDocument(); + }); + }); + + describe('when a file is selected that contains stories that conflict with existing ones', () => { + beforeEach(() => { + renderComponent(); + fireEvent.click(screen.getByText('onChange')); + }); + + it('displays a story chooser with the stories in the file', () => { + const storyChooser = screen.getByTestId('mock-story-chooser'); + + expect(storyChooser).toBeInTheDocument(); + expect(within(storyChooser).getByRole('listitem')).toHaveTextContent( + 'mock-story' + ); + }); + + it('still displays the file chooser', () => + expect(screen.getByTestId('mock-file-chooser')).toBeInTheDocument()); + + it("doesn't show a warning message if the file contained stories", () => + expect( + screen.queryByText('dialogs.storyImport.noStoriesInFile') + ).not.toBeInTheDocument()); + }); + + describe('when a file is selected that contains stories without conflicts', () => { + let onClose: jest.Mock; + + beforeEach(() => { + onClose = jest.fn(); + renderComponent({onClose}, []); + fireEvent.click(screen.getByText('onChange')); + }); + + it('immediately imports the stories', () => + expect(screen.getByTestId('story-inspector-default')).toHaveAttribute( + 'data-name', + 'mock-story' + )); + + it('closes', () => expect(onClose).toBeCalled()); + }); + + describe('when a file is selected that has no stories', () => { + beforeEach(() => { + renderComponent(); + fireEvent.click(screen.getByText('onChange no story')); + }); + + it('shows a message', () => + expect( + screen.getByText('dialogs.storyImport.noStoriesInFile') + ).toBeInTheDocument()); + + it('still displays the file chooser', () => + expect(screen.getByTestId('mock-file-chooser')).toBeInTheDocument()); + }); + + describe('when stories are selected', () => { + let onClose: jest.Mock; + + beforeEach(() => { + onClose = jest.fn(); + renderComponent({onClose}); + fireEvent.click(screen.getByText('onChange')); + fireEvent.click(screen.getByText('onImport')); + }); + + it('imports the selected stories into state', () => + expect(screen.getByTestId('story-inspector-default')).toHaveAttribute( + 'data-name', + 'mock-story' + )); + + it('closes', () => expect(onClose).toBeCalled()); + }); + + it('is accessible', async () => { + const {container} = renderComponent(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/src/dialogs/story-import/story-chooser.tsx b/src/dialogs/story-import/story-chooser.tsx index fd5b31586..ee02ad521 100644 --- a/src/dialogs/story-import/story-chooser.tsx +++ b/src/dialogs/story-import/story-chooser.tsx @@ -18,11 +18,21 @@ export const StoryChooser: React.FC = props => { const [selectedStories, setSelectedStories] = React.useState([]); const {t} = useTranslation(); - function willReplaceExisting(story: Story) { - return existingStories.some( - other => storyFileName(other) === storyFileName(story) - ); - } + // Whenever either existing stories or stories to import changes, select all + // stories that do not conflict. + + const willReplaceExisting = React.useCallback( + (story: Story) => { + return existingStories.some( + other => storyFileName(other) === storyFileName(story) + ); + }, + [existingStories] + ); + + React.useEffect(() => { + setSelectedStories(stories.filter(story => !willReplaceExisting(story))); + }, [stories, willReplaceExisting]); function handleChange(story: Story, selected: boolean) { if (selected) { @@ -39,9 +49,8 @@ export const StoryChooser: React.FC = props => {

{t('dialogs.storyImport.storiesPrompt')}

    {stories.map(story => ( -
  • +
  • handleChange(story, selected)} value={selectedStories.includes(story)} diff --git a/src/dialogs/story-import/story-import.tsx b/src/dialogs/story-import/story-import.tsx index 8c92d0bdd..7df470837 100644 --- a/src/dialogs/story-import/story-import.tsx +++ b/src/dialogs/story-import/story-import.tsx @@ -5,12 +5,13 @@ import { DialogCard, DialogCardProps } from '../../components/container/dialog-card'; +import {storyFileName} from '../../electron/shared'; import {importStories, Story, useStoriesContext} from '../../store/stories'; import {FileChooser} from './file-chooser'; import {StoryChooser} from './story-chooser'; import './story-import.css'; -export type StoryImportDialogProps = DialogCardProps; +export type StoryImportDialogProps = Omit; export const StoryImportDialog: React.FC = props => { const {onClose} = props; @@ -19,16 +20,30 @@ export const StoryImportDialog: React.FC = props => { const [file, setFile] = React.useState(); const [stories, setStories] = React.useState([]); - function handleFileChange(file: File, stories: Story[]) { - setFile(file); - setStories(stories); - } - function handleImport(stories: Story[]) { dispatch(importStories(stories, existingStories)); onClose(); } + function handleFileChange(file: File, stories: Story[]) { + // If there are no conflicts in the stories, import them now. Otherwise, set + // them in state and let the user choose via . + + if ( + stories.length === 0 || + stories.some(story => + existingStories.some( + existing => storyFileName(existing) === storyFileName(story) + ) + ) + ) { + setFile(file); + setStories(stories); + } else { + handleImport(stories); + } + } + return ( Date: Sat, 1 Oct 2022 22:20:27 -0400 Subject: [PATCH 13/43] Add missing mandatory props to dialog tests --- src/dialogs/__tests__/app-donation.test.tsx | 2 ++ src/dialogs/__tests__/app-prefs.test.tsx | 2 ++ src/dialogs/__tests__/passage-tags.test.tsx | 2 ++ src/dialogs/__tests__/story-javascript.test.tsx | 2 ++ src/dialogs/__tests__/story-search.test.tsx | 2 ++ src/dialogs/__tests__/story-stylesheet.test.tsx | 2 ++ src/dialogs/__tests__/story-tags.test.tsx | 2 ++ src/dialogs/about-twine/__tests__/about-twine.test.tsx | 2 ++ src/dialogs/passage-edit/__tests__/passage-edit.test.tsx | 1 + src/dialogs/story-details/__tests__/story-details.test.tsx | 2 ++ 10 files changed, 19 insertions(+) diff --git a/src/dialogs/__tests__/app-donation.test.tsx b/src/dialogs/__tests__/app-donation.test.tsx index b40422fd4..436991ea5 100644 --- a/src/dialogs/__tests__/app-donation.test.tsx +++ b/src/dialogs/__tests__/app-donation.test.tsx @@ -18,6 +18,8 @@ describe('', () => { diff --git a/src/dialogs/__tests__/app-prefs.test.tsx b/src/dialogs/__tests__/app-prefs.test.tsx index 320e9b2fc..7bd21171f 100644 --- a/src/dialogs/__tests__/app-prefs.test.tsx +++ b/src/dialogs/__tests__/app-prefs.test.tsx @@ -15,6 +15,8 @@ describe('', () => { diff --git a/src/dialogs/__tests__/passage-tags.test.tsx b/src/dialogs/__tests__/passage-tags.test.tsx index 58d023ecd..af064bfc4 100644 --- a/src/dialogs/__tests__/passage-tags.test.tsx +++ b/src/dialogs/__tests__/passage-tags.test.tsx @@ -33,6 +33,8 @@ describe('', () => { { diff --git a/src/dialogs/__tests__/story-search.test.tsx b/src/dialogs/__tests__/story-search.test.tsx index 6c4b9dee8..50b3416e0 100644 --- a/src/dialogs/__tests__/story-search.test.tsx +++ b/src/dialogs/__tests__/story-search.test.tsx @@ -23,6 +23,8 @@ const TestStorySearchDialog = () => { diff --git a/src/dialogs/__tests__/story-stylesheet.test.tsx b/src/dialogs/__tests__/story-stylesheet.test.tsx index 6de7ecc18..1ca77e041 100644 --- a/src/dialogs/__tests__/story-stylesheet.test.tsx +++ b/src/dialogs/__tests__/story-stylesheet.test.tsx @@ -19,6 +19,8 @@ const TestStoryStylesheetDialog = () => { diff --git a/src/dialogs/__tests__/story-tags.test.tsx b/src/dialogs/__tests__/story-tags.test.tsx index c435c144f..18a0d80b7 100644 --- a/src/dialogs/__tests__/story-tags.test.tsx +++ b/src/dialogs/__tests__/story-tags.test.tsx @@ -36,6 +36,8 @@ describe('', () => { diff --git a/src/dialogs/about-twine/__tests__/about-twine.test.tsx b/src/dialogs/about-twine/__tests__/about-twine.test.tsx index f1b3f6116..15e2a790d 100644 --- a/src/dialogs/about-twine/__tests__/about-twine.test.tsx +++ b/src/dialogs/about-twine/__tests__/about-twine.test.tsx @@ -9,6 +9,8 @@ describe('', () => { ); diff --git a/src/dialogs/passage-edit/__tests__/passage-edit.test.tsx b/src/dialogs/passage-edit/__tests__/passage-edit.test.tsx index ff0ee30c3..756752afa 100644 --- a/src/dialogs/passage-edit/__tests__/passage-edit.test.tsx +++ b/src/dialogs/passage-edit/__tests__/passage-edit.test.tsx @@ -27,6 +27,7 @@ const TestPassageEditDialog: React.FC< ', () => { Date: Sun, 2 Oct 2022 21:53:27 -0400 Subject: [PATCH 14/43] Update import docs --- docs/en/src/story-library/creating.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/en/src/story-library/creating.md b/docs/en/src/story-library/creating.md index 376c9289b..ff06290a5 100644 --- a/docs/en/src/story-library/creating.md +++ b/docs/en/src/story-library/creating.md @@ -29,11 +29,15 @@ To import stories or archives, the process is the same: 2. In the dialog that appears, choose the HTML file corresponding to your story or archive. If the file you want to import is disabled in the file dialog, it's because it's in a format that can't be used by Twine. -3. The dialog will show the story or stories Twine found in your file. Select - the ones you want to import. The dialog'll warn you if a story you're - importing has the same name as one already in your library. **If you do - choose to import it, it will overwrite your existing story completely.** -4. Use the _Import Selected Files_ button in the dialog to import the files +3. If the stories in the file you selected don't have the same name as any story + already in your library, Twine will import them immediately. +4. Otherwise, the dialog will show the story or stories Twine found in your + file. Select the ones you want to import; stories that won't overwrite + existing stories in your library are checked off for you by default. The + dialog'll warn you if a story you're importing has the same name as one + already in your library. **If you do choose to import it, it will overwrite + your existing story completely.** +5. Use the _Import Selected Files_ button in the dialog to import the files you've selected. If you change your mind about importing midway through the process, close the From bb173c0b5ea33f83376c3eec3ceb2a9d0583e146 Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Tue, 4 Oct 2022 22:24:28 -0400 Subject: [PATCH 15/43] Add Twee 3 utility functions --- src/util/__tests__/twee.test.ts | 136 ++++++++++++++++++++++++++++++++ src/util/twee.ts | 64 +++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 src/util/__tests__/twee.test.ts create mode 100644 src/util/twee.ts diff --git a/src/util/__tests__/twee.test.ts b/src/util/__tests__/twee.test.ts new file mode 100644 index 000000000..08984af9b --- /dev/null +++ b/src/util/__tests__/twee.test.ts @@ -0,0 +1,136 @@ +import {Story} from '../../store/stories'; +import {fakePassage, fakeStory} from '../../test-util'; +import {passageToTwee, storyToTwee} from '../twee'; + +describe('passageToTwee()', () => { + it('converts a passage with no tags properly', () => { + const passage = fakePassage({name: 'mock-passage', tags: []}); + + expect(passageToTwee(passage)).toBe( + `:: mock-passage {"position":"${passage.left},${passage.top}","size":"${passage.width},${passage.height}"}\n${passage.text}\n` + ); + }); + + it('converts a passage with tags properly', () => { + const passage = fakePassage({ + name: 'mock-passage', + tags: ['red', 'blue-green', 'yellow'] + }); + + expect(passageToTwee(passage)).toBe( + `:: mock-passage [red blue-green yellow] {"position":"${passage.left},${passage.top}","size":"${passage.width},${passage.height}"}\n${passage.text}\n` + ); + }); + + it('handles a passage name with special characters properly', () => { + const passage = fakePassage({ + name: '\\[weird{but possible}]\\', + tags: [] + }); + + expect(passageToTwee(passage)).toBe( + `:: \\\\\\[weird\\{but possible\\}\\]\\\\ {"position":"${passage.left},${passage.top}","size":"${passage.width},${passage.height}"}\n${passage.text}\n` + ); + }); + + it('handles a pssage name with leading and trailing spaces properly', () => { + const passage = fakePassage({ + name: ' two spaces ', + tags: [] + }); + + expect(passageToTwee(passage)).toBe( + `:: \\ \\ two spaces\\ \\ {"position":"${passage.left},${passage.top}","size":"${passage.width},${passage.height}"}\n${passage.text}\n` + ); + }); + + it('handles a tag name with special characters properly', () => { + const passage = fakePassage({ + name: 'mock-passage', + tags: ['[weird]', '{but-possible}', '\\slash'] + }); + + expect(passageToTwee(passage)).toBe( + `:: mock-passage [\\[weird\\] \\{but-possible\\} \\\\slash] {"position":"${passage.left},${passage.top}","size":"${passage.width},${passage.height}"}\n${passage.text}\n` + ); + }); + + it('preserves newlines and spaces in passage text', () => { + const passage = fakePassage({ + tags: [], + text: 'one\n two \n\n three ' + }); + + expect(passageToTwee(passage)).toBe( + `:: ${passage.name} {"position":"${passage.left},${passage.top}","size":"${passage.width},${passage.height}"}\none\n two \n\n three \n` + ); + }); + + it('escapes any lines starting with :: in passage text', () => { + const passage = fakePassage({ + tags: [], + text: ':: escape\n::and escape\n::one more time' + }); + + expect(passageToTwee(passage)).toBe( + `:: ${passage.name} {"position":"${passage.left},${passage.top}","size":"${passage.width},${passage.height}"}\n\\:\\: escape\n\\:\\:and escape\n\\:\\:one more time\n` + ); + }); +}); + +describe('storyToTwee()', () => { + let story: Story; + + beforeEach(() => (story = fakeStory(0))); + + it('creates a StoryTitle passage', () => + expect(storyToTwee(story)).toContain(`:: StoryTitle\n${story.name}\n\n`)); + + it('escapes a story name starting with ::', () => { + story.name = ':: weird'; + + expect(storyToTwee(story)).toContain(`:: StoryTitle\n\\:\\: weird\n\n`); + }); + + it('creates a StoryData passage with formatted JSON', () => { + story.passages = [fakePassage()]; + story.startPassage = story.passages[0].id; + story.tagColors = {'tag-name': 'red'}; + + expect(storyToTwee(story)).toContain( + `:: StoryData\n{\n "ifid": "${story.ifid}",\n "format": "${story.storyFormat}",\n "format-version": "${story.storyFormatVersion}",\n "start": "${story.passages[0].name}",\n "tag-colors": {\n "tag-name": "red"\n },\n "zoom": ${story.zoom}\n}\n` + ); + }); + + it("omits the start property in StoryData if the start passage couldn't be found", () => { + story.startPassage = 'nonexistent'; + story.tagColors = {'tag-name': 'red'}; + expect(storyToTwee(story)).toContain( + `:: StoryData\n{\n "ifid": "${story.ifid}",\n "format": "${story.storyFormat}",\n "format-version": "${story.storyFormatVersion}",\n "tag-colors": {\n "tag-name": "red"\n },\n "zoom": ${story.zoom}\n}\n` + ); + }); + + it('omits the tag-colors property in StoryData if none are set', () => { + story.tagColors = {}; + expect(storyToTwee(story)).toContain( + `:: StoryData\n{\n "ifid": "${story.ifid}",\n "format": "${story.storyFormat}",\n "format-version": "${story.storyFormatVersion}",\n "zoom": ${story.zoom}\n}\n` + ); + }); + + it('outputs converted passages with two newlines between them', () => { + story.passages = [fakePassage(), fakePassage(), fakePassage()]; + story.startPassage = story.passages[1].id; + story.tagColors = {'tag-name': 'red'}; + + const correctStoryTitle = `:: StoryTitle\n${story.name}`; + const correctStoryData = `:: StoryData\n{\n "ifid": "${story.ifid}",\n "format": "${story.storyFormat}",\n "format-version": "${story.storyFormatVersion}",\n "start": "${story.passages[1].name}",\n "tag-colors": {\n "tag-name": "red"\n },\n "zoom": ${story.zoom}\n}`; + const correctPassages = story.passages.map( + passage => + `:: ${passage.name} {"position":"${passage.left},${passage.top}","size":"${passage.width},${passage.height}"}\n${passage.text}` + ); + + expect(storyToTwee(story)).toBe( + `${correctStoryTitle}\n\n\n${correctStoryData}\n\n\n${correctPassages[0]}\n\n\n${correctPassages[1]}\n\n\n${correctPassages[2]}\n` + ); + }); +}); diff --git a/src/util/twee.ts b/src/util/twee.ts new file mode 100644 index 000000000..3870f239e --- /dev/null +++ b/src/util/twee.ts @@ -0,0 +1,64 @@ +import {Passage, Story} from '../store/stories'; + +/** + * Escapes characters with special meanings in a Twee passage header (brackets, + * curly quotes, and backslashes). + */ +export function escapeForTweeHeader(value: string) { + return value.replace(/\\/g, '\\\\').replace(/([[\]{}])/g, '\\$1'); +} + +/** + * Escapes characters that would disrupt parsing of passage text, i.e. `::` at + * the start of a line. + */ +export function escapeForTweeText(value: string) { + return value.replace(/^::/gm, '\\:\\:'); +} + +/** + * Converts a single passage to Twee. + */ +export function passageToTwee(passage: Passage) { + const escapedName = escapeForTweeHeader(passage.name) + .replace(/^\s+/g, match => '\\ '.repeat(match.length)) + .replace(/\s+$/g, match => '\\ '.repeat(match.length)); + const tags = + passage.tags.length > 0 + ? `[${passage.tags.map(escapeForTweeHeader).join(' ')}]` + : undefined; + const metadata = JSON.stringify({ + position: `${passage.left},${passage.top}`, + size: `${passage.width},${passage.height}` + }).replace(/\s+/g, ''); + const escapedText = escapeForTweeText(passage.text); + + return `:: ${escapedName}${ + tags ? ' ' + tags : '' + } ${metadata}\n${escapedText}\n`; +} + +/** + * Converts a story to Twee. + */ +export function storyToTwee(story: Story) { + const storyTitle = `:: StoryTitle\n${escapeForTweeText(story.name)}`; + const startPassage = story.passages.find(p => p.id === story.startPassage); + const storyData = `:: StoryData\n${JSON.stringify( + { + ifid: story.ifid, + format: story.storyFormat, + 'format-version': story.storyFormatVersion, + start: startPassage?.name, + 'tag-colors': + Object.keys(story.tagColors).length > 0 ? story.tagColors : undefined, + zoom: story.zoom + }, + null, + 2 + )}`; + + return `${storyTitle}\n\n\n${storyData}\n\n\n${story.passages + .map(passageToTwee) + .join('\n\n')}`; +} From 90df7ab4a225ff667dd2bea55332a5737edd7f13 Mon Sep 17 00:00:00 2001 From: "H. Utku Maden" Date: Mon, 10 Oct 2022 22:52:05 +0300 Subject: [PATCH 16/43] Update Turkish localization files. --- public/locales/tr.json | 386 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 350 insertions(+), 36 deletions(-) diff --git a/public/locales/tr.json b/public/locales/tr.json index e75d6b62b..8ec4e8c65 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -1,88 +1,362 @@ { - "colors": {}, + "colors": { + "none": "Renksiz", + "red": "Kırmızı", + "orange": "Turuncu", + "yellow": "Sarı", + "green": "Yeşil", + "blue": "Mavi", + "purple": "Mor" + }, "common": { "add": "Ekle", "appName": "Twine", + "back": "Geri", + "build": "İnşa Et", "cancel": "İptal", + "close": "Kapat", + "color": "Renk", + "create": "Oluştur", + "custom": "Özel", "delete": "Sil", + "deleteCount": "Sil ({{count}})", + "details": "Detaylar", "duplicate": "Kopya Oluştur", - "edit": "Düzen", + "edit": "Düzenle", + "editCount": "Düzenle ({{count}})", + "help": "Yardım", + "import": "İçe Aktar", + "maximize": "Kapla", + "more": "Daha Fazla", + "new": "Yeni", + "next": "Sonraki", "ok": "Tamam", - "play": "Oku", + "passage": "Bölüm", + "play": "Oynat", + "preferences": "Tercihler", + "publishToFile": "Dosyaya Yayımla", + "redo": "İleri Al", + "redoChange": "İleri Al ({{change}})", "rename": "Yeniden Adlandır", + "renamePrompt": "“{{name}}“ nasıl yeniden adlandırılsın?", "remove": "Kaldır", + "selectAll": "Hepsini Seç", "skip": "Atla", + "story": "Öykü", "storyFormat": "Öykü Biçimi", "tag": "Etiket", "test": "Dene", - "undo": "Geri Al" + "twine": "Twine", + "undo": "Geri Al", + "undoChange": "Geri Al ({{change}})", + "unmaximize": "Önceki Boyut", + "view": "Görünüm" }, "components": { "addStoryFormatButton": { "prompt": "Öykü biçimi eklemek için biçimin internet adresini aşağı girin." }, - "addTagButton": {}, - "fontSelect": {"fonts": {}}, - "indentButtons": {}, - "localStorageQuota": {}, + "addTagButton": { + "alreadyAdded": "Bu etiket adı zaten daha önceden eklendi.", + "addLabel": "Etiket Ekle", + "invalidName": "Lütfen geçerli bir etiket adı girin.", + "newTag": "Yeni Etiket", + "tagColorLabel": "Etiket Rengi", + "tagNameLabel": "Etiket Adı" + }, + "dialogCard": { + "contentsCrashed": "Bu iletişim kutusunda bir şeyler ters gitti. Lütfen kapatıp tekrar açmayı deneyin." + }, + "fontSelect": { + "customScaleDetail": "Lütfen yalnızca yüzdelik değer girin.", + "customFamilyDetail": "Lütfen yalnızca yazı tipinin adını girin.", + "familyEmpty": "Lütfen bir yazı tipi adı girin.", + "font": "Yazı Tipi", + "fonts": { + "monospaced": "Eşit Aralık (Monospace)", + "serif": "Serifli", + "system": "Sistem" + }, + "fontSize": "Yazı Boyutu", + "percentage": "%{{percent}}", + "percentageIsntNumber": "Lütfen bir sayı giriniz.", + "percentageNotPositive": "Lütfen sıfırdan büyük bir sayı giriniz." + }, + "indentButtons": { + "indent": "Girintile", + "unindent": "Girintiyi Çıkar" + }, + "localStorageQuota": { + "measureAgain": "Mevcut alanı tekrar ölçün", + "percentAvailable": "%{{percent}} yer mevcut" + }, "passageCard": { "placeholderClick": "Bölümü düzenlemek için çift tıklayın.", - "placeholderTouch": "Önce bu bölüme, sonra kaleme basarak bölümü düzenleyebilirsiniz." - }, - "renamePassageButton": {"emptyName": "Lütfen bir isim girin."}, - "renameStoryButton": {"emptyName": "Lütfen bir isim girin."}, - "safariWarningCard": {}, - "storyCard": {}, - "storyFormatCard": {}, - "storyFormatSelect": {}, - "tagEditor": {} + "placeholderTouch": "Önce bu bölümü seçerek, ardından Bölüm sekmesinden Düzenleye basarak bölümü düzenleyebilirsiniz." + }, + "renamePassageButton": { + "emptyName": "Lütfen bir isim girin.", + "nameAlreadyUsed": "Bu ada başka bir bölüm sahip." + }, + "renameStoryButton": { + "emptyName": "Lütfen bir isim girin.", + "nameAlreadyUsed": "Bu ada başka bir öykü sahip." + }, + "safariWarningCard": { + "archiveAndUseAnotherBrowser": "Lütfen öykülerinizi arşivleyin ve başka bir platform kullanın.", + "addToHomeScreen": "Bu sınırlamayı aşmak için bu siteyi ana ekranınıza ekleyin.", + "howToAddToHomeScreen": "Ana Ekranıma Nasıl Eklerim?", + "learnMore": "Daha Fazla Öğren", + "message": "Kullandığınız tarayıcı siteyi yedi gün boyunca kullanmadığınız takdirde tüm öykülerinizi siler." + }, + "storageQuota": { + "freeSpace": "%{{percent}} alan mevcut" + }, + "storyCard": { + "lastUpdated": "En son {{date}} tarihinde düzenlendi", + "passageCount": "1 Bölüm", + "passageCount_plural": "{{count}} Bölüm" + }, + "storyFormatCard": { + "author": "{{author}} tarafından", + "builtIn": "Dahili", + "defaultFormat": "Varsayılan biçim.", + "editorExtensionsDisabled": "Düzenleyici Eklentileri Kapalı", + "license": "Lisans: {{license}}", + "loadingFormat": "Bu öykü biçimi yükleniyor...", + "loadError": "Bu öykü biçimi yüklenemedi. ({{errorMessage}})", + "name": "{{name}} {{version}}", + "proofing": "İmla Düzeltme", + "proofingFormat": "İmla hatalarını düzeltmek için kullanılır.", + "useEditorExtensions": "Düzenleyici Eklentilerini Kullan", + "useFormat": "Varsayılan Öykü Biçimi Olarak Kullan", + "useProofingFormat": "İmla Düzeltme Biçimi Olarak Kullan" + }, + "storyFormatSelect": { + "loadingCount": "1 Öykü Biçimi Yükleniyor...", + "loadingCount_plural": "{{loadingCount}} Öykü Biçimi Yükleniyor" + }, + "tagEditor": { + "alreadyExists": "Bu isme sahip bir etiket zaten var." + } }, "dialogs": { "aboutTwine": { "donateToTwine": "Twine'ın Büyümesine Bağış Yaparak Katkıda Bulunun", - "codeHeader": "Kod Yazarları" + "codeHeader": "Kod Yazarları", + "codeRepo": "Kaynak Deposunu Ziyaret Et", + "license": "Bu yazılım GPL v3 Lisansı koşulları altında yayımlanmaktadır ancak bu yazılım kullanılarak oluşturulan herhangi bir eser ticari amaçlar da dahil olmak üzere herhangi bir koşulda yayımlanabilir.", + "localizationHeader": "Çeviriler", + "title": "Twine Hakkında {{version}}", + "twineDescription": "Twine, interaktif ve doğrusal olmayan öyküler oluşturmak için kullanılan bir açık kaynaklı yazılımdır." + }, + "appDonation": { + "donate": "Twine'a Bağışta Bulunun", + "onlyOnce": "(Bu mesaj size yalnızca bir defa gösterilecektir. Eğer gelecekte Twine'ın geliştirilmesi için bağışta bulunmak isterseniz Twine Hakkında iletişim kutusunda ilgili bağlantı mevcuttur.)", + "supportMessage": "Eğer Twine'ı çok sevdiyseniz lütfen geliştirilmesi için bağışta bulunarak yardım edin. Twine daima bedava kalacak bir açık kaynak projesidir, ve sizin de desteğinizle daha iyi yerlere gelecektir.", + "noThanks": "Hayır, Teşekkürler", + "title": "Twine'ın Geliştirilmesine Katkıda Bulunun" }, - "appDonation": {"noThanks": "Hayır, Teşekkürler"}, - "appPrefs": {"language": "Dil"}, - "passageEdit": {}, - "passageTags": {}, - "storyInfo": {"stats": {"title": "Öykü İstatistikleri"}}, + "appPrefs": { + "codeEditorFont": "Kod Düzenleyici Yazı Tipi", + "codeEditorFontScale": "Kod Düzenleyici Yazı Boyutu", + "dialogWidth": "İletişim Kutusu Eni", + "dialogWidths": { + "default": "Varsayılan", + "wider": "Daha Geniş", + "widest": "En Geniş" + }, + "editorCursorBlinks": "Düzenleyicide Yanıp Sönen İmleç", + "fontExplanation": "Yazı tipini burada değiştirmek yalnızca Twine düzenleyicsini etkiler. Oynatılan öykünün yazı tipi değiştirilmez.", + "language": "Dil", + "passageEditorFont": "Bölüm Düzenleyici Yazı Tipi", + "passageEditorFontScale": "Bölüm Düzenleyici Yazı Boyutu", + "themeLight": "Açık", + "themeDark": "Koyu", + "themeSystem": "Sistem", + "theme": "Tema", + "title": "Tercihler" + }, + "passageEdit": { + "editorCrashed": "Bu düzenleyicide bir şeyler ters gitti. Kapatıp yeniden açmayı deneyin.", + "passageTextEditorLabel": "Bölüm Metni", + "passageTextPlaceholder": "Bölümün metnini buraya yazın. Başka bir bölüme bağlantı oluşturmak için [[bunun gibi]] etrafına ikişer köşeli parantez koyun.", + "setAsStart": "Öyküyü Buradan Başlat", + "size": "Boyut", + "sizeLarge": "Büyük", + "sizeSmall": "Küçük", + "sizeTall": "Uzun", + "sizeWide": "Geniş" + }, + "passageTags": { + "noTags": "Bu öyküde hiçbir bölüme bir etiket eklenmedi.", + "title": "Bölüm Etiketleri" + }, + "storyImport": { + "deselectAll": "Seçimi Kaldır", + "fileImport": "Twine'a öykü aktarmak için arşivlenmiş ya da yayımlanmış bir öykü dosyasını aşağıdan yükleyin.", + "importDifferentFile": "Başka Bir Dosyayı İçe Aktar", + "importSelected": "Seçili Dosyaları İçe Aktar", + "importThisStory": "Bu Öyküyü İçe Aktar", + "noStoriesInFile": "Bu dosyada hiç Twine öyküsü yokmuş gibi görünüyor. Lütfen başka bir dosya seçin.", + "storiesPrompt": "Hangi öykülerin içe aktarılacağını seçin:", + "title": "Öykü İçe Aktar", + "willReplaceExisting": "Kitaplığınızdaki aynı isimli bir öykü yerine yenisi konacaktır." + }, + "storyDetails": { + "storyFormatExplanation": "Öykü biçimi nedir?", + "snapToGrid": "Kılavuza Uydur", + "stats": { + "brokenLinks": "Kırık Bağlantı", + "characters": "Karakter", + "title": "Öykü İstatistikleri", + "ifid": "Bu öykünün IFID'i {{ifid}}", + "ifidExplanation": "IFID nedir?", + "lastUpdate": "Bu öykü en son {{date}} tarihinde düzenlendi.", + "links": "Bağlantı", + "passages": "Bölüm", + "words": "Sözcük" + } + }, + "storyInfo": { "stats": {"title": "Öykü İstatistikleri"}}, "storyJavaScript": { - "explanation": "Buraya girdiğiniz JavaScript kodu; öykü bir İnternet tarayıcısında açıldığında istisnasız çalıştırılacaktır." + "editorLabel": "Öykü JavaScript'i", + "title": "Öykü JavaScript'i", + "explanation": "Buraya girdiğiniz JavaScript kodu; öykü bir İnternet tarayıcısında açıldığında anında çalıştırılacaktır." }, "storySearch": { "title": "Bul ve Değiştir", - "replaceWith": "Şununla Değiştir" + "find": "Bul", + "includePassageNames": "Bölüm Başlıklarını Dahil Et", + "matchCase": "Büyük Küçük Harf Eşleştir", + "matchCount": "{{count}} eşleşen bölüm", + "matchCount_plural": "{{count}} eşleşen bölüm", + "replaceAll": "Tüm Bölümlerde Değiştir", + "replaceWith": "Şununla Değiştir", + "useRegexes": "Düzenli İfade (RegEx) Kullan" }, "storyStylesheet": { + "editorLabel": "Öykü Stil Şablonu", + "title": "Öykü Stil Şablonu", "explanation": "Buraya girdiğiniz CSS kodu öykünün varsayılan görünümünü değiştirir." }, - "storyTags": {} + "storyTags": { + "noTags": "Öykülerinize hiçbir etiket eklenmedi.", + "title": "Öykü Etiketleri" + } }, "electron": { - "errors": {"storyFileChangedExternally": {}}, - "menuBar": {"edit": "Düzen"}, - "storiesDirectoryName": "Öyküler" + "backupsDirectoryName": "Yedekler", + "errors": { + "jsonSave": "Ayarlar dosyasını kaydederken bir hata oluştu.", + "storyFileChangedExternally": { + "message": "Kitaplığınızdaki “{{fileName}}” isimli dosya Twine'ın dışında düzenlenmiş.", + "detail": "Değişikliklerinizi kaydetmek bu dosyanın üstüne yazar. Eğer Twine'a şuan yüklü olan sürümü değil, dosyadaki sürümü kullanmak isterseniz Twine yeniden başlayacak ve değişiklikleriniz dosyadakilerle değiştirilecektir.", + "overwriteChoice": "Twine'daki Değişiklikleri Kaydet.", + "relaunchChoice": "Dosyayı Kullan ve Yeniden Başlat" + }, + "storyDelete": "Öykü silinirken bir şeyler ters gitti.", + "storyRename": "Öykü yeniden adlandırılırken bir hata oluştu.", + "storySave": "Öykü kaydedilirken bir hata oluştu." + }, + "menuBar": { + "checkForUpdates": "Güncellemeleri Kontrol Et...", + "edit": "Düzen", + "showDevTools": "Hata Ayıklama Konsolunu Göster", + "showStoryLibrary": "Öykü Kitaplığını Göster", + "speech": "Konuşma", + "troubleshooting": "Hata Giderme", + "twineHelp": "Twine Yardımı", + "view": "Görünüm" + }, + "storiesDirectoryName": "Öyküler", + "updateCheck": { + "download": "İndir", + "error": "Twine'ın güncellemeleri kontrol edilirken bir şeyler ters gitti.", + "updateAvailable": "Twine'ın yeni sürümü mevut.", + "upToDate": "Twine'ın mevcut olan en yeni sürümünü kullanmaktasınız." + } }, "routes": { "storyEdit": { + "toolbar": { + "findAndReplace": "Bul ve Değiştir", + "javaScript": "JavaScript", + "passageTags": "Bölüm Etiketleri", + "snapToGrid": "Kılavuza Uydur", + "startStoryHere": "Öyküye Buradan Başla", + "stylesheet": "Stil Şablonu", + "testFromHere": "Buradan Dene" + }, "topBar": { "addPassage": "Bölüm", "editJavaScript": "Öykünün JavaScript kodunu düzenle", "editStylesheet": "Öykünün Stil Şablonunu Düzenle", "findAndReplace": "Bul ve Değiştir", + "passageTags": "Bölüm Etiketlerini Düzenle", "proofStory": "Yazım Denetleme Nüshasına Bak", - "publishToFile": "Dosyaya Yayımla" + "publishToFile": "Dosyaya Yayımla", + "selectAllPassages": "Tüm Bölümleri Seç" + }, + "zoomButtons": { + "storyStructure": "Yalnızca Öykü Yapısını Göster", + "passageNames": "Yalnızca Bölüm Adlarını Göster", + "passageNamesAndExcerpts": "Bölüm Adlarını ve İçeriğin Kısımlarını Göster" } }, "storyFormatList": { - "title": {}, - "storyFormatExplanation": "Öykü biçimleri hikayenin okuma sırasında nasıl göründüğünü ve davrandığını belirler." + "noneVisible": "Seçtiğiniz kriterlere uygun öykü biçimi bulunmamaktadır.", + "show": "Show...", + "title": { + "all": "Tüm Öylü Biçimleri", + "current": "Güncel Öykü Biçimleri", + "user": "Kendinizce Eklenen Öykü Biçimleri" + }, + "toolbar": { + "addStoryFormatButton": { + "addPreview": "{{storyFormatName}} {{storyFormatVersion}} eklenecek.", + "alreadyAdded": "{{storyFormatName}} {{storyFormatVersion}} zaten ekli.", + "fetchError": "Bu adresteki öykü biçimi elde edilemedi. ({{errorMessage}})", + "invalidUrl": "Lütfen geçerli bir URL girin.", + "prompt": "Öykü biçimi eklemek için biçimin internet adresini aşağı girin." + }, + "disableFormatExtensions": "Düzenleyici Eklentilerini Kapat", + "enableFormatExtensions": "Düzenleyici Eklentilerini Aç", + "useAsDefaultFormat": "Varsayılan Biçim Olarak Kullan", + "useAsProofingFormat": "Öykü İmlası için Kullan" + }, + "storyFormatExplanation": "Öykü biçimleri öykünün okuma sırasında nasıl göründüğünü ve davrandığını belirler." }, - "storyImport": {}, "storyList": { + "library": "Kitaplık", "noStories": "Şu an Twine'da kayıtlı hiçbir öykü yok. Başlamak için yeni bir öykü yaratın veya var olan bir öyküyü içe aktarın.", + "taggedTitleCount": "1 Öykü Etiketli", + "taggedTitleCount_0": "Hiç Öykü Etiketli Değil", + "taggedTitleCount_plural": "{{count}} Öykü Etiketli", + "titleCount": "1 Öykü", + "titleCount_0": "Hiç Öykü Yok", + "titleCount_plural": "{{count}} Öykü", "titleGeneric": "Öyküler", + "toolbar": { + "archive": "Arşivle", + "createStoryButton": { + "prompt": "Öykünün adı ne olsun? Bu daha sonra değiştirilebilir.", + "emptyName": "Lütfen bir ad girin.", + "nameConflict": "Başka bir öykü zaten bu ada sahip." + }, + "deleteStoryButton": { + "warning": { + "electron": "“{{storyName}}” isimli öyküyü silmek istediğinizden emin misiniz? Geri dönüşüm kutusuna taşınacaktır.", + "web": "“{{storyName}}” isimli öyküyü silmek istediğinizden emin misiniz? Sonsuza dek silinecektir. Bunu geri alamazsınız." + } + }, + "showAllStories": "Tüm Öyküleri Göster", + "showTags": "Etiketleri Göster", + "sort": "Sırala", + "sortByDate": "Son Değiştirme Tarihi", + "sortByName": "Ad", + "storyTags": "Öykü Etiketleri" + }, "topBar": { "about": "Twine Hakkında", "archive": "Arşiv", @@ -93,21 +367,61 @@ } }, "welcome": { + "autosave": "

    Artık belgelerim klasörünüzde Twine isimli bir klasör var. Onun içinde tüm çalışmalarınızın kaydedileceği \"Stories\" (Öyküler) klasörü bulunmaktadır. Twine çalışmalarınız kaydeder, yani kendinizin kaydetmeyi hatırlaması gerekmez. Öykülerinizin kaydedildiği klasörü açmak için Twine menüsündeki Kitaplığı Göster seçeneğini kullanabilirsiniz.

    Twine öyküleriniz sürekli kaydettiği için Twine açıkken kitaplık klasörünüzdeki dosyalar kitlenecektir.

    Eğer başkasından aldığınız bir Twine öyküsünü açmak istiyorsanız öykü listesindeki Dosyadan İçe Aktar bağlantısını kullanarak kendi kitaplığınıza aktarabilirsiniz.

    ", "autosaveTitle": "Çalışmalarınız otomatik olarak kaydedilir.", + "browserStorage": "

    Bu Twine 2'yi kullanmak için bir hesap oluşturmanıza gerek kalmadığı anlamına gelir ve oluşturduğunuz hiç bir şey uzaktaki bir sunucuda saklanmaz&emdash;yalnızca tarayıcınızda kalır.

    Fakat iki çok önemli şeyi aklınızda bulundurmanız gerekmektedir. Verileriniz yalnızca tarayıcınızda saklandığından dolayı tarayıcının kayıtlı verilerini silerseniz tüm emeklerinizi boşa çıkarırsınız. Bu hiç iyi bir durum değil. Arşivle düğmesini sık sık kullanmayı unutmayın. Ayrıca, listedeki öykülerin menüsünü kullanarak onları teker teker yayımlayabilirsiniz. Hem arşiv hem de yayımlanmış öykü dosyaları Twine'a geri aktarılabilir.

    İkinci olarak bu tarayıcıya erişimi olan herkes öykülerinizde değişiklikler yapabilir. Eğer işinize karışmayı seven küçük kardeşiniz varsa kendinize ayrı bir profil oluşturmayı araştırın.

    ", + "browserStorageTitle": "Çalışmalarınız yalnızca tarayıcıya kaydedilmektedir.", + "done": "

    Okudunuz için teşekkür eder, Twine'ı kullanırken eğlenmenizi dileriz.

    ", "doneTitle": "Tamamdır!", "gotoStoryList": "Öykü listesine git", + "greeting": "

    Twine, interaktif ve doğrusal olmayan öyküler oluşturmak için kullanılan bir açık kaynaklı yazılımdır. Başlamadan önce bilmeniz gerekn bazı şeyler vardır.

    ", "greetingTitle": "Merhaba!", "tellMeMore": "Devam Et", + "help": "

    Eğer daha önce Twine'ı kullanmadıysanız hoş geldiniz! Twine Cookbok nasıl kullanıldığını öğrenmek için iyi bir kaynaktır. Eğer Twine'ı daha önce kullanmadıysanız başlamak için harika bir yer.

    ", "helpTitle": "Yeni misiniz?" } }, + "routeActions": { + "app": { + "aboutApp": "Twine Hakkında", + "preferences": "Tercihler", + "reportBug": "Hata Bildir", + "storyFormats": "Öykü Biçimleri" + }, + "build": { + "play": "Oynat", + "proof": "İmla Düzelt", + "publishToFile": "Dosyaya Yayımla", + "test": "Dene" + } + }, "store": { - "errors": {}, + "archiveFilename": "{{timestamp}} Twine Arşivi.html", + "errors": { + "cantPersistPerfs": "Tercihlerinizi kaydederken bir şeyler ters gitti ({{error}}).", + "cantPersistStories": "Öyküleriniz kaydederken bir şeyler ters gitti ({{error}}).", + "cantPersistStoryFormats": "Öykü biçimleriniz kaydederken bir şeyler ters gitti ({{error}}).", + "electronRemediation": "Uygulamayı yeniden başlatmak yardımcı olabilir.", + "webRemediation": "Sayfayı yenilemek yardımcı olabilir." + }, "passageDefaults": { "name": "Adsız Bölüm" }, "storyDefaults": {"name": "Adsız Öykü"}, "storyFormatDefaults": {"name": "Adsız Öykü Biçimi"} }, - "undoChange": {"replaceAllText": "Hepsini Değiştir"} -} + "undoChange": { + "addTag": "Etiket Ekleme", + "changeTagColor": "Etiket Rengini Değiştirme", + "newPassage": "Yeni Bölüm", + "deletePassage": "Bölüm Silme", + "deletePassages": "Bölümleri Silme", + "movePassage": "Bölüm Taşıma", + "movePassages": "Bölümleri Taşıma", + "imortTag": "Etiket Kaldırma", + "renamePassage": "Bölüm Yeniden Adlandırma", + "removeTag": "Etiketi Kaldırma", + "renameTag": "Etiketi Yeniden Adlandırma", + "replaceAllText": "Hepsini Değiştirme" + } +} \ No newline at end of file From 1efe120b068943bdcd4de85fb0605ecd5ddddbd5 Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Wed, 5 Oct 2022 22:32:18 -0400 Subject: [PATCH 17/43] Add Twee export --- docs/en/src/getting-started/basic-concepts.md | 10 +++++ docs/en/src/story-library/exporting.md | 13 ++++++- public/locales/en-US.json | 1 + src/components/image/icon/file-twee.tsx | 38 +++++++++++++++++++ src/components/image/icon/index.ts | 1 + .../shared/__tests__/story-filename.test.ts | 5 ++- src/electron/shared/story-filename.ts | 4 +- .../__tests__/build-actions.test.tsx | 19 +++++++++- src/route-actions/build-actions.tsx | 22 +++++++++-- .../library/__tests__/archive-button.test.tsx | 38 +++++++++++++++++++ .../toolbar/library/archive-button.tsx | 2 +- .../{save-html.test.ts => save-file.test.ts} | 4 +- src/util/__tests__/twee.test.ts | 14 +++++-- src/util/save-file.ts | 21 ++++++++++ src/util/save-html.ts | 10 ----- src/util/twee.ts | 5 ++- 16 files changed, 182 insertions(+), 25 deletions(-) create mode 100644 src/components/image/icon/file-twee.tsx create mode 100644 src/routes/story-list/toolbar/library/__tests__/archive-button.test.tsx rename src/util/__tests__/{save-html.test.ts => save-file.test.ts} (87%) create mode 100644 src/util/save-file.ts delete mode 100644 src/util/save-html.ts diff --git a/docs/en/src/getting-started/basic-concepts.md b/docs/en/src/getting-started/basic-concepts.md index 74d45658d..3590e9946 100644 --- a/docs/en/src/getting-started/basic-concepts.md +++ b/docs/en/src/getting-started/basic-concepts.md @@ -109,6 +109,16 @@ proofread your stories before you share it with a larger audience. Twine comes with one proofing format, called Paperthin, but others with more features exist in the Twine community. +## Twee + +Twee is a plain-text format for Twine stories. Twee files are not playable by +themselves, but are easier to edit and view in a text editor than the HTML files +that Twine creates. The Twee format is [documented +here](https://github.com/iftechfoundation/twine-specs/blob/master/twee-3-specification.md). + +You don't need to use Twee to build a story with Twine, but Twine can export +stories to Twee format for use with other tools. + [^start]: In Twine version 1, this passage had to be called "Start" (including the capital S). Current versions of Twine allow setting the start passage to any one in a story, regardless of name. \ No newline at end of file diff --git a/docs/en/src/story-library/exporting.md b/docs/en/src/story-library/exporting.md index 25f336930..77a49d7fd 100644 --- a/docs/en/src/story-library/exporting.md +++ b/docs/en/src/story-library/exporting.md @@ -19,4 +19,15 @@ This file can be either opened directly in a web browser to play your story, or [imported into Twine](creating.md). The other buttons under the _Build_ tab work the same as they do in the [Story -Map Screen](../editing-stories). \ No newline at end of file +Map Screen](../editing-stories). + +## Exporting a Story To Twee + +You can also export a story to [Twee +format](../getting-started/basic-concepts.html#twee). Select it and choose +_Export as Twee_ from the _Build_ top toolbar tab. You'll be asked where to save +this file. + +Twine creates Twee files with a `.twee` file suffix. Your system may not know +how to handle them by default, but they are openable in any plain text editor, +like Notepad on Windows or TextEdit on macOS. \ No newline at end of file diff --git a/public/locales/en-US.json b/public/locales/en-US.json index f0c64b0ec..0a2b2dcd4 100644 --- a/public/locales/en-US.json +++ b/public/locales/en-US.json @@ -377,6 +377,7 @@ "storyFormats": "Story Formats" }, "build": { + "exportAsTwee": "Export As Twee", "play": "Play", "proof": "Proof", "publishToFile": "Publish to File", diff --git a/src/components/image/icon/file-twee.tsx b/src/components/image/icon/file-twee.tsx new file mode 100644 index 000000000..7d359eb58 --- /dev/null +++ b/src/components/image/icon/file-twee.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; + +// export const IconTwee: React.FC = () => ( +// +// +// +// +// +// +// ); + +export const IconFileTwee: React.FC = () => ( + + + + + + + + +); diff --git a/src/components/image/icon/index.ts b/src/components/image/icon/index.ts index 5ea2db97e..2f63268c6 100644 --- a/src/components/image/icon/index.ts +++ b/src/components/image/icon/index.ts @@ -1,6 +1,7 @@ export * from './empty'; export * from './loading'; export * from './tag-nub'; +export * from './file-twee'; export * from './twine'; export * from './type-size'; export * from './zoom-out'; diff --git a/src/electron/shared/__tests__/story-filename.test.ts b/src/electron/shared/__tests__/story-filename.test.ts index 2483a0ff9..f424409f3 100644 --- a/src/electron/shared/__tests__/story-filename.test.ts +++ b/src/electron/shared/__tests__/story-filename.test.ts @@ -7,9 +7,12 @@ describe('storyFileName', () => { beforeEach(() => (story = fakeStory())); - it('adds a .html file suffix', () => + it('adds a .html file suffix by default', () => expect(storyFileName(story)).toBe(`${story.name}.html`)); + it('uses the file suffix specified', () => + expect(storyFileName(story, '.txt')).toBe(`${story.name}.txt`)); + it('maintains the case of the story', () => { expect(storyFileName({...story, name: story.name.toUpperCase()})).toBe( `${story.name.toUpperCase()}.html` diff --git a/src/electron/shared/story-filename.ts b/src/electron/shared/story-filename.ts index 12922e205..038a31752 100644 --- a/src/electron/shared/story-filename.ts +++ b/src/electron/shared/story-filename.ts @@ -6,6 +6,6 @@ import {Story} from '../../store/stories/stories.types'; * (http://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html#tag_03_276) * with the addition of spaces, for legibility. */ -export function storyFileName(story: Story) { - return story.name.replace(/[^\w. -]/g, '_') + '.html'; +export function storyFileName(story: Story, extension = '.html') { + return story.name.replace(/[^\w. -]/g, '_') + extension; } diff --git a/src/route-actions/__tests__/build-actions.test.tsx b/src/route-actions/__tests__/build-actions.test.tsx index 4a03fdbe5..13f5b333d 100644 --- a/src/route-actions/__tests__/build-actions.test.tsx +++ b/src/route-actions/__tests__/build-actions.test.tsx @@ -1,17 +1,21 @@ import {fireEvent, render, screen, waitFor} from '@testing-library/react'; import {axe} from 'jest-axe'; import * as React from 'react'; +import {storyFileName} from '../../electron/shared'; import {Story} from '../../store/stories'; import {usePublishing} from '../../store/use-publishing'; import {useStoryLaunch} from '../../store/use-story-launch'; import {fakeStory} from '../../test-util'; +import {saveTwee} from '../../util/save-file'; +import {storyToTwee} from '../../util/twee'; import {BuildActions, BuildActionsProps} from '../build-actions'; jest.mock('../../store/use-publishing'); jest.mock('../../store/use-story-launch'); -jest.mock('../../util/save-html'); +jest.mock('../../util/save-file'); describe('', () => { + const saveTweeMock = saveTwee as jest.Mock; const usePublishingMock = usePublishing as jest.Mock; const useStoryLaunchMock = useStoryLaunch as jest.Mock; @@ -39,6 +43,11 @@ describe('', () => { expect( screen.getByText('routeActions.build.publishToFile') ).toBeDisabled()); + + it('disables the export to Twee button', () => + expect( + screen.getByText('routeActions.build.exportAsTwee') + ).toBeDisabled()); }); describe('when given a story prop', () => { @@ -114,6 +123,14 @@ describe('', () => { expect(screen.getByText('mock-publish-error')).toBeInTheDocument() ); }); + + it('displays a button to export the story as Twee', () => { + expect(saveTweeMock).not.toHaveBeenCalled(); + fireEvent.click(screen.getByText('routeActions.build.exportAsTwee')); + expect(saveTweeMock.mock.calls).toEqual([ + [storyToTwee(story), storyFileName(story, '.twee')] + ]); + }); }); it('is accessible', async () => { diff --git a/src/route-actions/build-actions.tsx b/src/route-actions/build-actions.tsx index 936328482..1bf75723d 100644 --- a/src/route-actions/build-actions.tsx +++ b/src/route-actions/build-actions.tsx @@ -1,5 +1,5 @@ import { - IconEye, + IconEyeglass, IconFileText, IconPlayerPlay, IconTool, @@ -11,11 +11,13 @@ import {ButtonBar} from '../components/container/button-bar'; import {CardContent} from '../components/container/card'; import {CardButton} from '../components/control/card-button'; import {IconButton} from '../components/control/icon-button'; +import {IconFileTwee} from '../components/image/icon'; import {storyFileName} from '../electron/shared'; import {Story} from '../store/stories'; import {usePublishing} from '../store/use-publishing'; import {useStoryLaunch} from '../store/use-story-launch'; -import {saveHtml} from '../util/save-html'; +import {saveHtml, saveTwee} from '../util/save-file'; +import {storyToTwee} from '../util/twee'; export interface BuildActionsProps { story?: Story; @@ -93,6 +95,14 @@ export const BuildActions: React.FC = ({story}) => { } } + function handleExportAsTwee() { + if (!story) { + throw new Error('No story provided to export'); + } + + saveTwee(storyToTwee(story), storyFileName(story, '.twee')); + } + return ( = ({story}) => { } + icon={} label={t('routeActions.build.proof')} onChangeOpen={() => setProofError(undefined)} onClick={handleProof} @@ -171,6 +181,12 @@ export const BuildActions: React.FC = ({story}) => { /> + } + label={t('routeActions.build.exportAsTwee')} + onClick={handleExportAsTwee} + /> ); }; diff --git a/src/routes/story-list/toolbar/library/__tests__/archive-button.test.tsx b/src/routes/story-list/toolbar/library/__tests__/archive-button.test.tsx new file mode 100644 index 000000000..888374c32 --- /dev/null +++ b/src/routes/story-list/toolbar/library/__tests__/archive-button.test.tsx @@ -0,0 +1,38 @@ +import {fireEvent, render, screen} from '@testing-library/react'; +import {axe} from 'jest-axe'; +import * as React from 'react'; +import {Story} from '../../../../../store/stories'; +import {FakeStateProvider, fakeStory} from '../../../../../test-util'; +import {getAppInfo} from '../../../../../util/app-info'; +import {archiveFilename, publishArchive} from '../../../../../util/publish'; +import {saveHtml} from '../../../../../util/save-file'; +import {ArchiveButton} from '../archive-button'; + +jest.mock('../../../../../util/save-file'); + +describe('ArchiveButton', () => { + const saveHtmlMock = saveHtml as jest.Mock; + + function renderComponent(stories?: Story[]) { + return render( + + + + ); + } + + it('saves an HTML archive of all stories when clicked', () => { + const stories = [fakeStory(), fakeStory()]; + renderComponent(stories); + fireEvent.click(screen.getByText('routes.storyList.toolbar.archive')); + expect(saveHtmlMock.mock.calls).toEqual([ + [publishArchive(stories, getAppInfo()), archiveFilename()] + ]); + }); + + it('is accessible', async () => { + const {container} = renderComponent(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/src/routes/story-list/toolbar/library/archive-button.tsx b/src/routes/story-list/toolbar/library/archive-button.tsx index 097db781b..ff9e87e64 100644 --- a/src/routes/story-list/toolbar/library/archive-button.tsx +++ b/src/routes/story-list/toolbar/library/archive-button.tsx @@ -4,7 +4,7 @@ import {IconPackage} from '@tabler/icons'; import {IconButton} from '../../../../components/control/icon-button'; import {useStoriesContext} from '../../../../store/stories'; import {archiveFilename, publishArchive} from '../../../../util/publish'; -import {saveHtml} from '../../../../util/save-html'; +import {saveHtml} from '../../../../util/save-file'; import {getAppInfo} from '../../../../util/app-info'; export const ArchiveButton: React.FC = () => { diff --git a/src/util/__tests__/save-html.test.ts b/src/util/__tests__/save-file.test.ts similarity index 87% rename from src/util/__tests__/save-html.test.ts rename to src/util/__tests__/save-file.test.ts index 9783bcb72..26e9610f6 100644 --- a/src/util/__tests__/save-html.test.ts +++ b/src/util/__tests__/save-file.test.ts @@ -1,9 +1,9 @@ -import {saveHtml} from '../save-html'; +import {saveHtml} from '../save-file'; import {saveAs} from 'file-saver'; jest.mock('file-saver'); -fdescribe('saveHtml', () => { +describe('saveHtml()', () => { const saveAsMock = saveAs as jest.Mock; it('calls saveAs with an HTML blob', async () => { diff --git a/src/util/__tests__/twee.test.ts b/src/util/__tests__/twee.test.ts index 08984af9b..329758ebf 100644 --- a/src/util/__tests__/twee.test.ts +++ b/src/util/__tests__/twee.test.ts @@ -117,14 +117,22 @@ describe('storyToTwee()', () => { ); }); - it('outputs converted passages with two newlines between them', () => { - story.passages = [fakePassage(), fakePassage(), fakePassage()]; + it('outputs converted passages, sorted alphabetically, with two newlines between them', () => { + story.passages = [ + fakePassage({name: 'a'}), + fakePassage({name: 'c'}), + fakePassage({name: 'b'}) + ]; story.startPassage = story.passages[1].id; story.tagColors = {'tag-name': 'red'}; const correctStoryTitle = `:: StoryTitle\n${story.name}`; const correctStoryData = `:: StoryData\n{\n "ifid": "${story.ifid}",\n "format": "${story.storyFormat}",\n "format-version": "${story.storyFormatVersion}",\n "start": "${story.passages[1].name}",\n "tag-colors": {\n "tag-name": "red"\n },\n "zoom": ${story.zoom}\n}`; - const correctPassages = story.passages.map( + const correctPassages = [ + story.passages[0], + story.passages[2], + story.passages[1] + ].map( passage => `:: ${passage.name} {"position":"${passage.left},${passage.top}","size":"${passage.width},${passage.height}"}\n${passage.text}` ); diff --git a/src/util/save-file.ts b/src/util/save-file.ts new file mode 100644 index 000000000..764f58114 --- /dev/null +++ b/src/util/save-file.ts @@ -0,0 +1,21 @@ +import {saveAs} from 'file-saver'; + +/** + * Saves text to an HTML file. This works in either a browser or Electron + * context. + */ +export function saveHtml(source: string, filename: string) { + const data = new Blob([source], {type: 'text/html;charset=utf-8'}); + + saveAs(data, filename); +} + +/** + * Saves text to a Twee file. This works in either a browser or Electron + * context. + */ +export function saveTwee(source: string, filename: string) { + const data = new Blob([source], {type: 'text/plain;charset=utf-8'}); + + saveAs(data, filename); +} diff --git a/src/util/save-html.ts b/src/util/save-html.ts deleted file mode 100644 index 0fae1feab..000000000 --- a/src/util/save-html.ts +++ /dev/null @@ -1,10 +0,0 @@ -import {saveAs} from 'file-saver'; - -/** - * Saves text to a file. This works in either a browser or Electron context. - */ -export function saveHtml(source: string, filename: string) { - const data = new Blob([source], {type: 'text/html;charset=utf-8'}); - - saveAs(data, filename); -} diff --git a/src/util/twee.ts b/src/util/twee.ts index 3870f239e..92cfb4827 100644 --- a/src/util/twee.ts +++ b/src/util/twee.ts @@ -1,3 +1,4 @@ +import {sortBy} from 'lodash'; import {Passage, Story} from '../store/stories'; /** @@ -58,7 +59,9 @@ export function storyToTwee(story: Story) { 2 )}`; - return `${storyTitle}\n\n\n${storyData}\n\n\n${story.passages + return `${storyTitle}\n\n\n${storyData}\n\n\n${sortBy(story.passages, [ + 'name' + ]) .map(passageToTwee) .join('\n\n')}`; } From 0dc6e99a8184ade3ed544925d6b4315f2c280650 Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Sun, 23 Oct 2022 17:45:20 -0400 Subject: [PATCH 18/43] Add Twee import --- docs/en/src/story-library/creating.md | 20 +- public/locales/en-US.json | 2 +- .../__tests__/file-chooser.test.tsx | 4 +- src/dialogs/story-import/file-chooser.tsx | 9 +- src/util/__tests__/twee.test.ts | 456 +++++++++++++++++- src/util/import.ts | 2 +- src/util/twee.ts | 270 ++++++++++- 7 files changed, 745 insertions(+), 18 deletions(-) diff --git a/docs/en/src/story-library/creating.md b/docs/en/src/story-library/creating.md index ff06290a5..28fdf633e 100644 --- a/docs/en/src/story-library/creating.md +++ b/docs/en/src/story-library/creating.md @@ -20,13 +20,13 @@ _Story_ top toolbar tab. Twine will create a copy for you with a unique name. ## Importing Stories -Twine can import stories in progress, published stories, and exported archives. -It cannot, however, import stories from Twine 1 or Twee source code. +Twine can import stories in progress, published stories, exported archives, and +Twee source code. It cannot, however, import stories from Twine 1. To import stories or archives, the process is the same: 1. Choose _Import_ from the _Library_ top toolbar tab. -2. In the dialog that appears, choose the HTML file corresponding to your story +2. In the dialog that appears, choose the file corresponding to your story or archive. If the file you want to import is disabled in the file dialog, it's because it's in a format that can't be used by Twine. 3. If the stories in the file you selected don't have the same name as any story @@ -41,4 +41,16 @@ To import stories or archives, the process is the same: you've selected. If you change your mind about importing midway through the process, close the -dialog or choose a different file to restart the process. \ No newline at end of file +dialog or choose a different file to restart the process. + +## Twee Import Limitations + +Twine will use the story and passage metadata present in Twee source code, such +as passage position or story name. If this metadata is not present, Twine will +try to substitute reasonable defaults, but it will not handle all cases +perfectly. In particular: + +- If Twee source code does not include passage positions, Twine will place + passages in a grid pattern. +- If a Twee file does not specify what story format and version it uses, Twine + will set it to [the default story format](../story-formats/default.html). diff --git a/public/locales/en-US.json b/public/locales/en-US.json index 0a2b2dcd4..0fa96150f 100644 --- a/public/locales/en-US.json +++ b/public/locales/en-US.json @@ -191,7 +191,7 @@ }, "storyImport": { "deselectAll": "Deselect All", - "filePrompt": "To import stories into Twine, upload either an archive or published story file below.", + "filePrompt": "To import stories into Twine, upload an archive, published story, or Twee source file below.", "importDifferentFile": "Import a Different File", "importSelected": "Import Selected Files", "importThisStory": "Import This Story", diff --git a/src/dialogs/story-import/__tests__/file-chooser.test.tsx b/src/dialogs/story-import/__tests__/file-chooser.test.tsx index 767fa554c..475750f5e 100644 --- a/src/dialogs/story-import/__tests__/file-chooser.test.tsx +++ b/src/dialogs/story-import/__tests__/file-chooser.test.tsx @@ -7,13 +7,13 @@ describe('FileChooser', () => { return render(); } - it('displays a file input that accepts only HTML files', () => { + it('displays a file input that accepts HTML and Twee files', () => { renderComponent(); const input = screen.getByLabelText('dialogs.storyImport.filePrompt'); expect(input).toBeInTheDocument(); - expect(input).toHaveAttribute('accept', '.html'); + expect(input).toHaveAttribute('accept', '.html,.twee,.tw'); }); // Todo for the same reason this test is todo on FileInput under components. diff --git a/src/dialogs/story-import/file-chooser.tsx b/src/dialogs/story-import/file-chooser.tsx index d129bdcc8..f3fa33da7 100644 --- a/src/dialogs/story-import/file-chooser.tsx +++ b/src/dialogs/story-import/file-chooser.tsx @@ -3,6 +3,7 @@ import {useTranslation} from 'react-i18next'; import {FileInput} from '../../components/control/file-input'; import {Story} from '../../store/stories'; import {importStories} from '../../util/import'; +import {storyFromTwee} from '../../util/twee'; export interface FileChooserProps { onChange: (file: File, stories: Story[]) => void; @@ -13,14 +14,18 @@ export const FileChooser: React.FC = props => { const {t} = useTranslation(); function handleChange(file: File, data: string) { - onChange(file, importStories(data)); + if (/\.html$/.test(file.name)) { + onChange(file, importStories(data)); + } else { + onChange(file, [storyFromTwee(data)]); + } } return (

    diff --git a/src/util/__tests__/twee.test.ts b/src/util/__tests__/twee.test.ts index 329758ebf..eb19969b3 100644 --- a/src/util/__tests__/twee.test.ts +++ b/src/util/__tests__/twee.test.ts @@ -1,6 +1,270 @@ -import {Story} from '../../store/stories'; +import {Passage, Story} from '../../store/stories'; import {fakePassage, fakeStory} from '../../test-util'; -import {passageToTwee, storyToTwee} from '../twee'; +import { + passageFromTwee, + passageToTwee, + storyFromTwee, + storyToTwee +} from '../twee'; + +function passageObject(props: Partial) { + return expect.objectContaining({ + height: 100, + highlighted: false, + id: expect.any(String), + left: 0, + selected: false, + width: 100, + top: 0, + ...props + }); +} + +describe('passageFromTwee()', () => { + it('converts a passage with no tags properly', () => { + const passage = fakePassage(); + + expect(passageFromTwee(`:: ${passage.name}\n${passage.text}`)).toEqual( + passageObject({ + left: 0, + name: passage.name, + tags: [], + text: passage.text + }) + ); + }); + + it('converts a passage with tags properly', () => { + const passage = fakePassage(); + + expect( + passageFromTwee( + `:: ${passage.name} [red blue-green yellow]\n${passage.text}` + ) + ).toEqual( + passageObject({ + name: passage.name, + tags: ['red', 'blue-green', 'yellow'], + text: passage.text + }) + ); + }); + + it('converts a passage with metadata properly', () => { + const passage = fakePassage(); + + expect( + passageFromTwee( + `:: ${passage.name} {"position":"${passage.left},${passage.top}","size":"${passage.width},${passage.height}"}\n${passage.text}` + ) + ).toEqual( + passageObject({ + height: passage.height, + left: passage.left, + name: passage.name, + tags: [], + top: passage.top, + text: passage.text, + width: passage.width + }) + ); + }); + + it('converts a passage with tags and metadata properly', () => { + const passage = fakePassage(); + + expect( + passageFromTwee( + `:: ${passage.name} [red blue-green yellow] {"position":"${passage.left},${passage.top}","size":"${passage.width},${passage.height}"}\n${passage.text}` + ) + ).toEqual( + passageObject({ + height: passage.height, + left: passage.left, + name: passage.name, + tags: ['red', 'blue-green', 'yellow'], + top: passage.top, + text: passage.text, + width: passage.width + }) + ); + }); + + it('converts a passage with an escaped name properly', () => { + const passage = fakePassage({name: '\\oops[{'}); + + expect(passageFromTwee(`:: \\oops\\[\\{\n${passage.text}`)).toEqual( + passageObject({ + name: passage.name, + tags: [], + text: passage.text + }) + ); + }); + + it('converts a passage with escaped tags properly', () => { + const passage = fakePassage({tags: ['\\', '[]', '{}']}); + + expect( + passageFromTwee( + `:: ${passage.name} [\\\\ \\[\\] \\{\\}]\n${passage.text}` + ) + ).toEqual( + passageObject({ + name: passage.name, + tags: passage.tags, + text: passage.text + }) + ); + }); + + it('converts a passage with escaped text properly', () => { + const passage = fakePassage({text: ':: bad\n::worse'}); + + expect(passageFromTwee(`:: ${passage.name}\n\\:: bad\n\\::worse`)).toEqual( + passageObject({ + name: passage.name, + tags: passage.tags, + text: passage.text + }) + ); + }); + + it('trims whitespace from the end of text', () => { + expect(passageFromTwee(`:: Long\ntext\n\n\n\n`)).toEqual( + passageObject({name: 'Long', text: 'text'}) + ); + }); + + it('converts a passage with no whitespace properly', () => { + const passage = fakePassage({tags: ['red', 'blue-green', 'yellow']}); + + expect( + passageFromTwee( + `::${passage.name}[red blue-green yellow]{"position":"${passage.left},${passage.top}","size":"${passage.width},${passage.height}"}\n${passage.text}` + ) + ).toEqual( + passageObject({ + height: passage.height, + left: passage.left, + name: passage.name, + tags: passage.tags, + text: passage.text, + top: passage.top, + width: passage.width + }) + ); + }); + + it('converts a passage with extra whitespace properly', () => { + const passage = fakePassage({tags: ['red', 'blue-green', 'yellow']}); + + expect( + passageFromTwee( + `:: ${passage.name} [ red blue-green yellow ] { "position" : "${passage.left},${passage.top}", "size" : "${passage.width},${passage.height}" }\n${passage.text}` + ) + ).toEqual( + passageObject({ + height: passage.height, + left: passage.left, + name: passage.name, + tags: passage.tags, + text: passage.text, + top: passage.top, + width: passage.width + }) + ); + }); + + it('converts a passage with empty tags properly', () => { + expect(passageFromTwee(':: No Tags []')).toEqual( + passageObject({ + name: 'No Tags', + tags: [], + text: '' + }) + ); + + expect(passageFromTwee(':: No Tags [ ]')).toEqual( + passageObject({ + name: 'No Tags', + tags: [], + text: '' + }) + ); + }); + + it('converts a passage with empty metadata properly', () => { + expect(passageFromTwee(':: No Metadata {}')).toEqual( + passageObject({ + name: 'No Metadata', + tags: [], + text: '' + }) + ); + expect(passageFromTwee(':: No Metadata Or Tags [] {}')).toEqual( + passageObject({ + name: 'No Metadata Or Tags', + tags: [], + text: '' + }) + ); + }); + + it('ignores malformed metadata', () => { + const oldWarn = jest.spyOn(console, 'warn').mockReturnValue(); + + expect(passageFromTwee(':: Bad Metadata {1 + 1}')).toEqual( + passageObject({ + name: 'Bad Metadata', + tags: [], + text: '' + }) + ); + expect(passageFromTwee(":: This Isn't Metadata {1 + 1")).toEqual( + passageObject({ + name: "This Isn't Metadata {1 + 1", + tags: [], + text: '' + }) + ); + expect( + passageFromTwee(':: Bad Position {"position": 32,"size":"10,20"}') + ).toEqual( + passageObject({ + height: 20, + name: 'Bad Position', + tags: [], + text: '', + width: 10 + }) + ); + expect( + passageFromTwee(':: Bad Size {"position":"10,20","size": 32}') + ).toEqual( + passageObject({ + left: 10, + name: 'Bad Size', + tags: [], + text: '', + top: 20 + }) + ); + oldWarn.mockRestore(); + }); + + it('throws an error if given a passage with no name or text', () => + expect(() => passageFromTwee('::')).toThrow()); + + it('converts a passage with no text properly', () => + expect(passageFromTwee(':: No Text')).toEqual( + passageObject({ + name: 'No Text', + tags: [], + text: '' + }) + )); +}); describe('passageToTwee()', () => { it('converts a passage with no tags properly', () => { @@ -73,8 +337,154 @@ describe('passageToTwee()', () => { }); expect(passageToTwee(passage)).toBe( - `:: ${passage.name} {"position":"${passage.left},${passage.top}","size":"${passage.width},${passage.height}"}\n\\:\\: escape\n\\:\\:and escape\n\\:\\:one more time\n` + `:: ${passage.name} {"position":"${passage.left},${passage.top}","size":"${passage.width},${passage.height}"}\n\\:: escape\n\\::and escape\n\\::one more time\n` + ); + }); +}); + +describe('storyFromTwee()', () => { + let story: Story; + + beforeEach(() => (story = fakeStory(2))); + + it('imports passages', () => { + const oldWarn = jest.spyOn(console, 'warn').mockReturnValue(); + const {passages: p} = story; + const {passages} = storyFromTwee( + `:: ${p[0].name}\n${p[0].text}\n::${p[1].name}\n${p[1].text}` + ); + + expect(passages.length).toBe(2); + expect(passages[0]).toEqual( + expect.objectContaining({name: p[0].name, text: p[0].text}) + ); + expect(passages[1]).toEqual( + expect.objectContaining({name: p[1].name, text: p[1].text}) + ); + oldWarn.mockRestore(); + }); + + it('sets the story name if a StoryTitle passage exists', () => { + const oldWarn = jest.spyOn(console, 'warn').mockReturnValue(); + const result = storyFromTwee( + ':: StoryTitle\ntest-title\n:: Another Passage\ntest-text' + ); + + expect(result.name).toBe('test-title'); + expect(result.passages.length).toBe(1); + expect(result.passages[0]).toEqual( + expect.objectContaining({ + name: 'Another Passage', + text: 'test-text' + }) + ); + oldWarn.mockRestore(); + }); + + it('applies metadata in a StoryData passage', () => { + const story = fakeStory(0); + const data = { + ifid: story.ifid, + format: story.storyFormat, + 'format-version': story.storyFormatVersion, + 'tag-colors': story.tagColors, + zoom: story.zoom + }; + + expect(storyFromTwee(`:: StoryData\n${JSON.stringify(data)}`)).toEqual( + expect.objectContaining({ + ifid: story.ifid, + storyFormat: story.storyFormat, + storyFormatVersion: story.storyFormatVersion, + tagColors: story.tagColors, + zoom: story.zoom + }) + ); + }); + + it("concatenates all script-tagged passages into the story's script property", () => { + const oldWarn = jest.spyOn(console, 'warn').mockReturnValue(); + + expect( + storyFromTwee( + ':: test-script [script]\nabc\n:: test-script2 [script]\ndef' + ).script + ).toBe('abc\ndef'); + oldWarn.mockRestore(); + }); + + it("concatenates all stylesheet-tagged passages into the story's style property", () => { + const oldWarn = jest.spyOn(console, 'warn').mockReturnValue(); + + expect( + storyFromTwee( + ':: test-script [stylesheet]\nabc\n:: test-script2 [stylesheet]\ndef' + ).stylesheet + ).toBe('abc\ndef'); + oldWarn.mockRestore(); + }); + + it('sets the start passage if it can be found', () => { + const story = fakeStory(1); + const result = storyFromTwee( + `:: ${story.passages[0].name}\n:: StoryData\n{"start": "${story.passages[0].name}"}` ); + + expect(result.passages.length).toBe(1); + expect(result.startPassage).toBe(result.passages[0].id); + }); + + it('ignores extraneous metadata in a StoryData passage', () => + expect(storyFromTwee(`:: StoryData\n{"bad": true}`)).not.toEqual({ + bad: true + })); + + it('ignores StoryData metadata of an incorrect type', () => { + const badData = { + ifid: true, + format: 3, + 'format-version': 1, + 'tag-colors': 'bad', + zoom: null + }; + + expect( + storyFromTwee(`:: StoryData\n${JSON.stringify(badData)}`) + ).not.toEqual( + expect.objectContaining({ + ifid: badData.ifid, + storyFormat: badData.format, + storyFormatVersion: badData['format-version'], + tagColors: badData['tag-colors'], + zoom: badData.zoom + }) + ); + }); + + it('arranges passages in a grid 10 passages wide if none have any position metadata set', () => { + const oldWarn = jest.spyOn(console, 'warn').mockReturnValue(); + let source = ''; + + for (let i = 0; i < 11; i++) { + source += `:: ${i}\n`; + } + + const {passages} = storyFromTwee(source); + + expect(passages).toEqual([ + expect.objectContaining({left: 25, name: '0', top: 25}), + expect.objectContaining({left: 150, name: '1', top: 25}), + expect.objectContaining({left: 275, name: '2', top: 25}), + expect.objectContaining({left: 400, name: '3', top: 25}), + expect.objectContaining({left: 525, name: '4', top: 25}), + expect.objectContaining({left: 650, name: '5', top: 25}), + expect.objectContaining({left: 775, name: '6', top: 25}), + expect.objectContaining({left: 900, name: '7', top: 25}), + expect.objectContaining({left: 1025, name: '8', top: 25}), + expect.objectContaining({left: 1150, name: '9', top: 25}), + expect.objectContaining({left: 25, name: '10', top: 150}) + ]); + oldWarn.mockRestore(); }); }); @@ -89,7 +499,7 @@ describe('storyToTwee()', () => { it('escapes a story name starting with ::', () => { story.name = ':: weird'; - expect(storyToTwee(story)).toContain(`:: StoryTitle\n\\:\\: weird\n\n`); + expect(storyToTwee(story)).toContain(`:: StoryTitle\n\\:: weird\n\n`); }); it('creates a StoryData passage with formatted JSON', () => { @@ -117,13 +527,15 @@ describe('storyToTwee()', () => { ); }); - it('outputs converted passages, sorted alphabetically, with two newlines between them', () => { + it('returns converted passages, sorted alphabetically, with two newlines between them', () => { story.passages = [ fakePassage({name: 'a'}), fakePassage({name: 'c'}), fakePassage({name: 'b'}) ]; + story.script = ''; story.startPassage = story.passages[1].id; + story.stylesheet = ''; story.tagColors = {'tag-name': 'red'}; const correctStoryTitle = `:: StoryTitle\n${story.name}`; @@ -141,4 +553,38 @@ describe('storyToTwee()', () => { `${correctStoryTitle}\n\n\n${correctStoryData}\n\n\n${correctPassages[0]}\n\n\n${correctPassages[1]}\n\n\n${correctPassages[2]}\n` ); }); + + it('creates a passage tagged "script" if the story has a script property', () => { + story.passages = []; + story.stylesheet = ''; + + expect(storyToTwee(story)).toMatch( + `:: StoryScript [script]\n${story.script}` + ); + }); + + it('renames the script passage if a passage in the story conflicts with it', () => { + story.passages = [fakePassage({name: 'StoryScript'})]; + story.stylesheet = ''; + expect(storyToTwee(story)).toMatch( + `:: StoryScript 1 [script]\n${story.script}` + ); + }); + + it('creates a passage tagged "style" if the story has a stylesheet property', () => { + story.passages = []; + story.script = ''; + + expect(storyToTwee(story)).toMatch( + `:: StoryStylesheet [stylesheet]\n${story.stylesheet}` + ); + }); + + it('renames the style passage if a passage in the story conflicts with it', () => { + story.passages = [fakePassage({name: 'StoryStylesheet'})]; + story.script = ''; + expect(storyToTwee(story)).toMatch( + `:: StoryStylesheet 1 [stylesheet]\n${story.stylesheet}` + ); + }); }); diff --git a/src/util/import.ts b/src/util/import.ts index b2b4e89dd..0dda5e0af 100644 --- a/src/util/import.ts +++ b/src/util/import.ts @@ -87,7 +87,7 @@ function domToObject(storyEl: Element): ImportedStory { : [], zoom: parseFloat(storyEl.getAttribute('zoom') ?? '1'), tagColors: query(storyEl, selectors.tagColors).reduce((result, el) => { - const tagName = el.getAttribute('name'); + const tagName: string | null = el.getAttribute('name'); if (typeof tagName !== 'string') { return result; diff --git a/src/util/twee.ts b/src/util/twee.ts index 92cfb4827..201e55851 100644 --- a/src/util/twee.ts +++ b/src/util/twee.ts @@ -1,5 +1,9 @@ +import uuid from 'tiny-uuid'; import {sortBy} from 'lodash'; -import {Passage, Story} from '../store/stories'; +import {Passage, passageDefaults, Story, storyDefaults} from '../store/stories'; +import {unusedName} from './unused-name'; + +const linebreakRegExp = /\r?\n/; /** * Escapes characters with special meanings in a Twee passage header (brackets, @@ -14,7 +18,7 @@ export function escapeForTweeHeader(value: string) { * the start of a line. */ export function escapeForTweeText(value: string) { - return value.replace(/^::/gm, '\\:\\:'); + return value.replace(/^::/gm, '\\::'); } /** @@ -39,6 +43,227 @@ export function passageToTwee(passage: Passage) { } ${metadata}\n${escapedText}\n`; } +/** + * Converts Twee source to a passage. If it can't be parsed, then an error is + * thrown. If it can partially parse the passage, it will do so. + */ +export function passageFromTwee(source: string): Omit { + const [headerLine, ...lines] = source.split(linebreakRegExp); + + // The first line should be the header, with name, tags, and metadata. + // Roughly translating this regexp: + // ::, whitespace, name, whitespace, [tags]?, whitespace, {metadata}?, whitespace + const headerBits = /^::\s*(.*?)\s*(\[.*?\])?\s*(\{.*?\})?\s*$/.exec( + headerLine + ); + + if (!headerBits) { + throw new Error(`Header line couldn't be parsed: ${headerLine}`); + } + + const [, rawName, rawTags, rawMetadata] = headerBits; + + if (rawName.trim() === '') { + throw new Error( + `Passage name couldn't be found in header line: ${headerLine}` + ); + } + + const passage: Omit = { + ...passageDefaults(), + id: uuid(), + name: unescapeForTweeHeader(rawName.trim()), + tags: [], + text: lines.map(unescapeForTweeText).join('\n').trim() + }; + + if (rawTags) { + // Remove enclosing brackets and split on whitespace. + + passage.tags = rawTags + .replace(/^\[(.*)\]$/g, '$1') + .split(/\s/) + .filter(tag => tag.trim() !== '') + .map(tag => unescapeForTweeHeader(tag)); + } + + if (rawMetadata) { + // Try to parse it as JSON. + + try { + const metadata = JSON.parse(rawMetadata); + + if (typeof metadata.position === 'string') { + const [left, top] = metadata.position.split(',').map(parseFloat); + + if (typeof left === 'number' && typeof top === 'number') { + passage.left = left; + passage.top = top; + } else { + console.warn( + `Couldn't parse passage position metadata ${metadata.position}` + ); + } + } + + if (typeof metadata.size === 'string') { + const [width, height] = metadata.size.split(',').map(parseFloat); + + if (typeof width === 'number' && typeof height === 'number') { + passage.width = width; + passage.height = height; + } else { + console.warn(`Couldn't parse passage size metadata ${metadata.size}`); + } + } + } catch (error) { + console.warn(`Couldn't parse passage metadata ${rawMetadata}`); + } + } + + return passage; +} + +/** + * Converts a story from Twee source. + */ +export function storyFromTwee(source: string) { + const id = uuid(); + + const story: Story = { + ...storyDefaults(), + id, + ifid: uuid(), + lastUpdate: new Date(), + passages: source + .split(/^::/m) + .filter(s => s.trim() !== '') + .map(s => ':: ' + s) + .map(passageFromTwee) + .map(passage => ({...passage, story: id})), + script: '' + }; + + // Remove all passages with a script or stylesheet tags and put them in the + // story's properties instead. + + story.passages = story.passages.filter(passage => { + const isScript = passage.tags.includes('script'); + const isStylesheet = passage.tags.includes('stylesheet'); + + // If the passage has neither *or* both tags, treat it as normal. Behavior + // when a passage is tagged with both is not currently spec'd, but let's + // assume the user is confused. + + if ((!isScript && !isStylesheet) || (isScript && isStylesheet)) { + return true; + } + + if (isScript) { + story.script += passage.text + '\n'; + } else if (isStylesheet) { + story.stylesheet += passage.text + '\n'; + } + + return false; + }); + + // Trim any extra whitespace in the script and stylesheet we created above. + + story.script = story.script.trim(); + story.stylesheet = story.stylesheet.trim(); + + // If there is a StoryTitle passage, remove it and set the story name. + + const titlePassageIndex = story.passages.findIndex( + passage => passage.name === 'StoryTitle' + ); + + if (titlePassageIndex !== -1) { + story.name = story.passages[titlePassageIndex].text.trim(); + story.passages.splice(titlePassageIndex, 1); + } + + // If there is a StoryData passage, remove it and apply properties contained + // there. + + const dataPassageIndex = story.passages.findIndex( + passage => passage.name === 'StoryData' + ); + + if (dataPassageIndex !== -1) { + const dataPassage = story.passages[dataPassageIndex]; + + story.passages.splice(dataPassageIndex, 1); + + try { + const { + ifid, + format, + 'format-version': formatVersion, + start, + 'tag-colors': tagColors, + zoom + } = JSON.parse(dataPassage.text); + + if (typeof ifid === 'string') { + story.ifid = ifid; + } + + if (typeof format === 'string') { + story.storyFormat = format; + } + + if (typeof formatVersion === 'string') { + story.storyFormatVersion = formatVersion; + } + + if (typeof start === 'string') { + const startPassage = story.passages.find( + passage => passage.name === start + ); + + if (startPassage) { + story.startPassage = startPassage.id; + } else { + console.warn(`Couldn't find start passage with name "${start}"`); + } + } + + if (typeof tagColors === 'object') { + for (const tagName in tagColors) { + if (typeof tagColors[tagName] === 'string') { + story.tagColors[tagName] = tagColors[tagName]; + } else { + console.warn(`Tag "${tagName}" has non-string color`); + } + } + } + + if (typeof zoom === 'number') { + story.zoom = zoom; + } + } catch (error) { + console.warn(`Couldn't parse story data: ${dataPassage.text}`); + } + } else { + console.warn('No StoryData passage is present in Twee'); + } + + // Detect old Twee format, which would have no passage metadata, and put + // passages in a grid. + + if (story.passages.every(({left, top}) => left === 0 && top === 0)) { + story.passages = story.passages.map((passage, index) => ({ + ...passage, + left: 25 + 125 * (index % 10), + top: 25 + 125 * Math.floor(index / 10) + })); + } + + return story; +} + /** * Converts a story to Twee. */ @@ -59,9 +284,48 @@ export function storyToTwee(story: Story) { 2 )}`; - return `${storyTitle}\n\n\n${storyData}\n\n\n${sortBy(story.passages, [ + let result = `${storyTitle}\n\n\n${storyData}\n\n\n${sortBy(story.passages, [ 'name' ]) .map(passageToTwee) .join('\n\n')}`; + + // If the story has script or stylesheet, they need to be converted to tagged + // passages. These passage names are not part of the Twee spec. + + const passageNames = story.passages.map(({name}) => name); + + if (story.script.trim() !== '') { + const scriptPassageName = unusedName('StoryScript', passageNames); + + result += `\n\n:: ${scriptPassageName} [script]\n${escapeForTweeText( + story.script + )}`; + } + + if (story.stylesheet.trim() !== '') { + const stylesheetPassageName = unusedName('StoryStylesheet', passageNames); + + result += `\n\n:: ${stylesheetPassageName} [stylesheet]\n${escapeForTweeText( + story.stylesheet + )}`; + } + + return result; +} + +/** + * Unescapes characters with special meanings in a Twee passage header (brackets, + * curly quotes, and backslashes). + */ +export function unescapeForTweeHeader(value: string) { + return value.replace(/\\([[\]{}])/g, '$1').replace(/\\\\/g, '\\'); +} + +/** + * Unescapes characters that would disrupt parsing of passage text, i.e. `::` at + * the start of a line. + */ +export function unescapeForTweeText(value: string) { + return value.replace(/^\\:/gm, ':'); } From dcc903b887b0709286e6cfd0383ad257564aea55 Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Tue, 25 Oct 2022 22:57:09 -0400 Subject: [PATCH 19/43] Repair stories after import Cribs from https://github.com/klembot/twinejs/pull/1300 --- src/dialogs/about-twine/credits.json | 1 + .../__tests__/story-import.test.tsx | 11 ++ src/dialogs/story-import/story-import.tsx | 3 + src/store/__tests__/state-loader.test.tsx | 77 +++--------- .../__tests__/use-stories-repair.test.tsx | 118 ++++++++++++++++++ src/store/state-loader.tsx | 51 ++------ src/store/use-publishing.ts | 2 +- src/store/use-stories-repair.tsx | 56 +++++++++ 8 files changed, 215 insertions(+), 104 deletions(-) create mode 100644 src/store/__tests__/use-stories-repair.test.tsx create mode 100644 src/store/use-stories-repair.tsx diff --git a/src/dialogs/about-twine/credits.json b/src/dialogs/about-twine/credits.json index bc74a4a8e..90ff9e2ff 100644 --- a/src/dialogs/about-twine/credits.json +++ b/src/dialogs/about-twine/credits.json @@ -6,6 +6,7 @@ "Daithi O Crualaoich", "Thomas Michael Edwards", "Micah Fitch", + "Kate Fractal", "Juhana Leinonen", "Michael Savich", "Ross Smith" diff --git a/src/dialogs/story-import/__tests__/story-import.test.tsx b/src/dialogs/story-import/__tests__/story-import.test.tsx index c9a63df40..583b78284 100644 --- a/src/dialogs/story-import/__tests__/story-import.test.tsx +++ b/src/dialogs/story-import/__tests__/story-import.test.tsx @@ -1,13 +1,19 @@ import {fireEvent, render, screen, within} from '@testing-library/react'; import {axe} from 'jest-axe'; import {Story} from '../../../store/stories'; +import {useStoriesRepair} from '../../../store/use-stories-repair'; import {FakeStateProvider, fakeStory, StoryInspector} from '../../../test-util'; import {StoryImportDialog, StoryImportDialogProps} from '../story-import'; +jest.mock('../../../store/use-stories-repair'); jest.mock('../file-chooser'); jest.mock('../story-chooser'); describe('StoryImportDialog', () => { + const useStoriesRepairMock = useStoriesRepair as jest.Mock; + + beforeEach(() => useStoriesRepairMock.mockReturnValue(jest.fn())); + function renderComponent( props?: Partial, stories?: Story[] @@ -98,9 +104,12 @@ describe('StoryImportDialog', () => { describe('when stories are selected', () => { let onClose: jest.Mock; + let repairStories: jest.Mock; beforeEach(() => { onClose = jest.fn(); + repairStories = jest.fn(); + useStoriesRepairMock.mockReturnValue(repairStories); renderComponent({onClose}); fireEvent.click(screen.getByText('onChange')); fireEvent.click(screen.getByText('onImport')); @@ -112,6 +121,8 @@ describe('StoryImportDialog', () => { 'mock-story' )); + it('repairs all stories', () => expect(repairStories).toBeCalledTimes(1)); + it('closes', () => expect(onClose).toBeCalled()); }); diff --git a/src/dialogs/story-import/story-import.tsx b/src/dialogs/story-import/story-import.tsx index 7df470837..00803703b 100644 --- a/src/dialogs/story-import/story-import.tsx +++ b/src/dialogs/story-import/story-import.tsx @@ -7,6 +7,7 @@ import { } from '../../components/container/dialog-card'; import {storyFileName} from '../../electron/shared'; import {importStories, Story, useStoriesContext} from '../../store/stories'; +import {useStoriesRepair} from '../../store/use-stories-repair'; import {FileChooser} from './file-chooser'; import {StoryChooser} from './story-chooser'; import './story-import.css'; @@ -16,12 +17,14 @@ export type StoryImportDialogProps = Omit; export const StoryImportDialog: React.FC = props => { const {onClose} = props; const {t} = useTranslation(); + const repairStories = useStoriesRepair(); const {dispatch, stories: existingStories} = useStoriesContext(); const [file, setFile] = React.useState(); const [stories, setStories] = React.useState([]); function handleImport(stories: Story[]) { dispatch(importStories(stories, existingStories)); + repairStories(); onClose(); } diff --git a/src/store/__tests__/state-loader.test.tsx b/src/store/__tests__/state-loader.test.tsx index 2219ce06a..8a1de4375 100644 --- a/src/store/__tests__/state-loader.test.tsx +++ b/src/store/__tests__/state-loader.test.tsx @@ -1,14 +1,15 @@ import {act, render, screen, waitFor} from '@testing-library/react'; +import {fakeUnloadedStoryFormat} from '../../test-util'; +import {usePersistence} from '../persistence/use-persistence'; +import {usePrefsContext} from '../prefs'; import {StateLoader} from '../state-loader'; -import {defaults, usePrefsContext} from '../prefs'; import {useStoriesContext} from '../stories'; import { - useStoryFormatsContext, StoryFormat, - StoryFormatsAction + StoryFormatsAction, + useStoryFormatsContext } from '../story-formats'; -import {usePersistence} from '../persistence/use-persistence'; -import {fakeUnloadedStoryFormat} from '../../test-util'; +import * as useStoriesRepairModule from '../use-stories-repair'; jest.mock('../prefs/prefs-context'); jest.mock('../stories/stories-context'); @@ -90,6 +91,17 @@ describe('', () => { ); }); + it('repairs stories using useStoriesRepair', async () => { + const repairStories = jest.fn(); + + jest + .spyOn(useStoriesRepairModule, 'useStoriesRepair') + .mockReturnValue(repairStories); + render(); + await waitFor(() => expect(storiesDispatchMock).toBeCalled()); + expect(repairStories).toBeCalledTimes(1); + }); + it('uses the repaired story format state when repairing preferences', async () => { const repairedFormats = [ defaultFormat, @@ -172,59 +184,4 @@ describe('', () => { ); expect(screen.getByTestId('children')).toBeInTheDocument(); }); - - it('falls back to the default story format if the user preference is for an nonexistent format', async () => { - const defaultFormatProps = defaults().storyFormat; - const defaultFormat = fakeUnloadedStoryFormat({ - name: defaultFormatProps.name, - version: defaultFormatProps.version - }); - - (usePrefsContext as jest.Mock).mockReturnValue({ - dispatch: prefsDispatchMock, - prefs: { - storyFormat: { - name: 'bad', - version: '1.0.0' - } - } - }); - (useStoryFormatsContext as jest.Mock).mockReturnValue({ - dispatch: formatsDispatchMock, - formats: [defaultFormat] - }); - - render(); - await waitFor(() => expect(prefsDispatchMock).toBeCalled()); - expect(storiesDispatchMock.mock.calls).toEqual([ - [{type: 'init', state: {mockStoriesState: true}}], - [ - { - type: 'repair', - allFormats: [defaultFormat], - defaultFormat: defaultFormat - } - ] - ]); - }); - - it("does not repair stories if even the default story format isn't available", async () => { - jest.spyOn(console, 'error').mockReturnValue(); - (usePrefsContext as jest.Mock).mockReturnValue({ - dispatch: prefsDispatchMock, - prefs: { - storyFormat: { - name: 'bad', - version: '1.0.0' - } - } - }); - render(); - await waitFor(() => expect(prefsDispatchMock).toBeCalled()); - expect(storiesDispatchMock.mock.calls).toEqual([ - [{type: 'init', state: {mockStoriesState: true}}] - ]); - }); - - it('uses the repaired story formats to repair preferences', () => {}); }); diff --git a/src/store/__tests__/use-stories-repair.test.tsx b/src/store/__tests__/use-stories-repair.test.tsx new file mode 100644 index 000000000..9a16287b2 --- /dev/null +++ b/src/store/__tests__/use-stories-repair.test.tsx @@ -0,0 +1,118 @@ +import * as React from 'react'; +import {renderHook} from '@testing-library/react-hooks'; +import {fakePrefs, fakeUnloadedStoryFormat} from '../../test-util'; +import {defaults, PrefsContext} from '../prefs'; +import {StoriesContext} from '../stories'; +import {StoryFormatsContext} from '../story-formats'; +import {useStoriesRepair} from '../use-stories-repair'; + +describe('useStoriesRepair', () => { + it('returns a function which dispatches a repair action with the default format and all formats', () => { + const dispatch = jest.fn(); + const format = fakeUnloadedStoryFormat(); + const allFormats = [format, fakeUnloadedStoryFormat()]; + const prefs = fakePrefs({ + storyFormat: {name: format.name, version: format.version} + }); + const wrapper = ({children}: {children: React.ReactChild}) => ( + + + + {children} + + + + ); + const {result} = renderHook(() => useStoriesRepair(), {wrapper}); + + result.current(); + expect(dispatch.mock.calls).toEqual([ + [ + { + allFormats, + defaultFormat: format, + type: 'repair' + } + ] + ]); + }); + + it('falls back to the default story format if the user preference is for an nonexistent format', async () => { + const dispatch = jest.fn(); + const defaultFormatProps = defaults().storyFormat; + const defaultFormat = fakeUnloadedStoryFormat({ + name: defaultFormatProps.name, + version: defaultFormatProps.version + }); + const prefs = fakePrefs({ + storyFormat: { + name: 'bad', + version: '1.0.0' + } + }); + const allFormats = [defaultFormat, fakeUnloadedStoryFormat()]; + const wrapper = ({children}: {children: React.ReactChild}) => ( + + + + {children} + + + + ); + + const {result} = renderHook(() => useStoriesRepair(), {wrapper}); + + result.current(); + expect(dispatch.mock.calls).toEqual([ + [ + { + allFormats, + defaultFormat: defaultFormat, + type: 'repair' + } + ] + ]); + }); + + it("does nothing if even the default story format isn't available", async () => { + const oldError = jest.spyOn(console, 'error').mockReturnValue(); + const dispatch = jest.fn(); + const prefs = fakePrefs({ + storyFormat: { + name: 'bad', + version: '1.0.0' + } + }); + const wrapper = ({children}: {children: React.ReactChild}) => ( + + + + {children} + + + + ); + + const {result} = renderHook(() => useStoriesRepair(), {wrapper}); + + result.current(); + expect(dispatch).not.toBeCalled(); + oldError.mockRestore(); + }); +}); diff --git a/src/store/state-loader.tsx b/src/store/state-loader.tsx index 3d5c20151..8949e0e04 100644 --- a/src/store/state-loader.tsx +++ b/src/store/state-loader.tsx @@ -1,13 +1,10 @@ import * as React from 'react'; -import {defaults, usePrefsContext} from './prefs'; -import {useStoriesContext} from './stories'; -import { - formatWithNameAndVersion, - StoryFormat, - useStoryFormatsContext -} from './story-formats'; -import {usePersistence} from './persistence/use-persistence'; import {LoadingCurtain} from '../components/loading-curtain'; +import {usePersistence} from './persistence/use-persistence'; +import {usePrefsContext} from './prefs'; +import {useStoriesContext} from './stories'; +import {useStoryFormatsContext} from './story-formats'; +import {useStoriesRepair} from './use-stories-repair'; export const StateLoader: React.FC = ({children}) => { const [initing, setIniting] = React.useState(false); @@ -19,6 +16,7 @@ export const StateLoader: React.FC = ({children}) => { const {dispatch: storiesDispatch} = useStoriesContext(); const {dispatch: formatsDispatch, formats: formatsState} = useStoryFormatsContext(); + const repairStories = useStoriesRepair(); const {prefs, stories, storyFormats} = usePersistence(); // Done in steps so that the repair action can see the inited state, and then @@ -70,41 +68,7 @@ export const StateLoader: React.FC = ({children}) => { React.useEffect(() => { if (inited && formatsRepaired && prefsRepaired && !storiesRepaired) { - // We try to repair stories to the user's preferred format, but perhaps - // their prefs are out of date/corrupted. In that case, we use the default - // one. - - let safeFormat: StoryFormat | undefined; - - try { - safeFormat = formatWithNameAndVersion( - formatsState, - prefsState.storyFormat.name, - prefsState.storyFormat.version - ); - } catch { - try { - safeFormat = formatWithNameAndVersion( - formatsState, - defaults().storyFormat.name, - defaults().storyFormat.version - ); - } catch (error) { - console.error( - `Could not locate a safe story format, skipping story repair: ${ - (error as Error).message - }` - ); - } - } - - if (safeFormat) { - storiesDispatch({ - type: 'repair', - allFormats: formatsState, - defaultFormat: safeFormat - }); - } + repairStories(); setStoriesRepaired(true); } }, [ @@ -116,6 +80,7 @@ export const StateLoader: React.FC = ({children}) => { prefsRepaired, prefsState.storyFormat.name, prefsState.storyFormat.version, + repairStories, stories, storiesDispatch, storiesRepaired diff --git a/src/store/use-publishing.ts b/src/store/use-publishing.ts index 0c8a489f9..592eaedbc 100644 --- a/src/store/use-publishing.ts +++ b/src/store/use-publishing.ts @@ -25,7 +25,7 @@ export interface UsePublishingProps { } /** - * A React hook publish stories from context. You probably want to use + * A React hook to publish stories from context. You probably want to use * `useStoryLaunch` instead--this is for doing the actual binding of the story * and story format. */ diff --git a/src/store/use-stories-repair.tsx b/src/store/use-stories-repair.tsx new file mode 100644 index 000000000..3c2513c92 --- /dev/null +++ b/src/store/use-stories-repair.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import {defaults, usePrefsContext} from './prefs'; +import {useStoriesContext} from './stories'; +import { + formatWithNameAndVersion, + StoryFormat, + useStoryFormatsContext +} from './story-formats'; + +/** + * A React hook to dispatch a repair action on the stories store. Like + * publishing, this spans the stories, preferences, and formats stores. + */ +export function useStoriesRepair() { + const {dispatch} = useStoriesContext(); + const {prefs} = usePrefsContext(); + const {formats} = useStoryFormatsContext(); + + return React.useCallback(() => { + // We try to repair stories to the user's preferred format, but perhaps + // their prefs are out of date/corrupted. In that case, we use the default + // one. + + let safeFormat: StoryFormat | undefined; + + try { + safeFormat = formatWithNameAndVersion( + formats, + prefs.storyFormat.name, + prefs.storyFormat.version + ); + } catch { + try { + safeFormat = formatWithNameAndVersion( + formats, + defaults().storyFormat.name, + defaults().storyFormat.version + ); + } catch (error) { + console.error( + `Could not locate a safe story format, skipping story repair: ${ + (error as Error).message + }` + ); + } + } + + if (safeFormat) { + dispatch({ + type: 'repair', + allFormats: formats, + defaultFormat: safeFormat + }); + } + }, [formats, prefs.storyFormat.name, prefs.storyFormat.version, dispatch]); +} From ff3c0df555233697fe94575e30c2b52d5459fedb Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Thu, 27 Oct 2022 00:09:00 -0400 Subject: [PATCH 20/43] Manually manage pending onChange calls --- src/dialogs/passage-edit/passage-text.tsx | 98 +++++++++++++------ .../stories/action-creators/update-passage.ts | 5 - 2 files changed, 67 insertions(+), 36 deletions(-) diff --git a/src/dialogs/passage-edit/passage-text.tsx b/src/dialogs/passage-edit/passage-text.tsx index 956196e42..a36bef1fa 100644 --- a/src/dialogs/passage-edit/passage-text.tsx +++ b/src/dialogs/passage-edit/passage-text.tsx @@ -1,4 +1,3 @@ -import {debounce} from 'lodash'; import * as React from 'react'; import {useTranslation} from 'react-i18next'; import {DialogEditor} from '../../components/container/dialog-card'; @@ -28,7 +27,6 @@ export const PassageText: React.FC = props => { storyFormat, storyFormatExtensionsDisabled } = props; - const [changePending, setChangePending] = React.useState(false); const [localText, setLocalText] = React.useState(passage.text); const {prefs} = usePrefsContext(); const autocompletePassageNames = useCodeMirrorPassageHints(story); @@ -36,6 +34,12 @@ export const PassageText: React.FC = props => { useFormatCodeMirrorMode(storyFormat.name, storyFormat.version) ?? 'text'; const {t} = useTranslation(); + // These are refs so that changing them doesn't trigger a rerender, and more + // importantly, no React effects fire. + + const onChangeText = React.useRef(); + const onChangeTimeout = React.useRef(); + // Effects to handle debouncing updates upward. The idea here is that the // component maintains a local state so that the CodeMirror instance always is // up-to-date with what the user has typed, but the global context may not be. @@ -47,23 +51,69 @@ export const PassageText: React.FC = props => { // replace. We ignore this if a change is pending so that users don't see // things they've typed in disappear or be replaced. - if (!changePending && localText !== passage.text) { + if (!onChangeTimeout.current && localText !== passage.text) { setLocalText(passage.text); } - }, [changePending, localText, passage.text]); - - // The code below handles user changes in the text field. 1 second is a - // guesstimate. - - const debouncedOnChange = React.useMemo( - () => - debounce((value: string) => { - onChange(value); - setChangePending(false); - }, 1000), - [onChange] + }, [localText, passage.text]); + + const handleLocalChange = React.useCallback( + ( + editor: CodeMirror.Editor, + data: CodeMirror.EditorChange, + text: string + ) => { + // A local change has been made, e.g. the user has typed or pasted into + // the field. It's safe to immediately trigger a CodeMirror editor update. + + onEditorChange(editor); + + // Set local state because the CodeMirror instance is controlled, and + // updates there should be immediate. + + setLocalText(text); + + // If there was a pending update, cancel it. + + if (onChangeTimeout.current) { + window.clearTimeout(onChangeTimeout.current); + } + + // Save the text value in case we need to reset the timeout in the next + // effect. + + onChangeText.current = text; + + // Queue a call to onChange. + + onChangeTimeout.current = window.setTimeout(() => { + // Important to reset this ref so that we don't try to cancel fired + // timeouts above. + + onChangeTimeout.current = undefined; + + // Finally call the onChange prop. + + onChange(onChangeText.current!); + }, 1000); + }, + [onChange, onEditorChange] ); + // If the onChange prop changes while an onChange call is pending, reset the + // timeout and point it to the correct callback. + + React.useEffect(() => { + if (onChangeTimeout.current) { + window.clearTimeout(onChangeTimeout.current); + onChangeTimeout.current = window.setTimeout(() => { + // This body must be the same as in the timeout in the previous effect. + + onChangeTimeout.current = undefined; + onChange(onChangeText.current!); + }, 1000); + } + }, [onChange]); + const handleMount = React.useCallback( (editor: CodeMirror.Editor) => { onEditorChange(editor); @@ -80,20 +130,6 @@ export const PassageText: React.FC = props => { [onEditorChange] ); - const handleChange = React.useCallback( - ( - editor: CodeMirror.Editor, - data: CodeMirror.EditorChange, - text: string - ) => { - onEditorChange(editor); - setChangePending(true); - debouncedOnChange(text); - setLocalText(text); - }, - [debouncedOnChange, onEditorChange] - ); - const options = React.useMemo( () => ({ ...codeMirrorOptionsFromPrefs(prefs), @@ -116,11 +152,11 @@ export const PassageText: React.FC = props => { fontScale={prefs.passageEditorFontScale} label={t('dialogs.passageEdit.passageTextEditorLabel')} labelHidden - onBeforeChange={handleChange} + onBeforeChange={handleLocalChange} onChange={onEditorChange} options={options} value={localText} /> ); -}; +}; \ No newline at end of file diff --git a/src/store/stories/action-creators/update-passage.ts b/src/store/stories/action-creators/update-passage.ts index 165faa160..a66713cbe 100644 --- a/src/store/stories/action-creators/update-passage.ts +++ b/src/store/stories/action-creators/update-passage.ts @@ -51,11 +51,6 @@ export function updatePassage( // We need to get an up-to-date version of the story so placement of new // passages is correct. - // - // still causes passage bounces sometimes :( this is because the placement - // algorithm works differently based on the number of passages it sees. - // will anyone care?? could there be a 'suggested positions'? how would we - // communicate back and forth? const updatedStory = storyWithId(getState(), story.id); From d27816c1c4dbef1207236e52b4c6a131f6ca7583 Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Sun, 30 Oct 2022 11:19:36 -0400 Subject: [PATCH 21/43] Add E2E smoke tests --- .github/workflows/eslint.yml | 21 ++++ .github/workflows/{prs.yml => jest.yml} | 10 +- .github/workflows/playwright.yml | 28 +++++ .gitignore | 5 +- e2e/smoke-test.spec.ts | 148 ++++++++++++++++++++++++ package-lock.json | 49 +++++++- package.json | 2 + playwright.config.ts | 107 +++++++++++++++++ 8 files changed, 361 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/eslint.yml rename .github/workflows/{prs.yml => jest.yml} (72%) create mode 100644 .github/workflows/playwright.yml create mode 100644 e2e/smoke-test.spec.ts create mode 100644 playwright.config.ts diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml new file mode 100644 index 000000000..991762675 --- /dev/null +++ b/.github/workflows/eslint.yml @@ -0,0 +1,21 @@ +name: ESLint + +on: + push: + branches: [develop] + pull_request: + branches: [develop, main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + with: + cache: npm + node-version: 16 + - name: Install + run: npm ci + - name: Link + run: npm run lint diff --git a/.github/workflows/prs.yml b/.github/workflows/jest.yml similarity index 72% rename from .github/workflows/prs.yml rename to .github/workflows/jest.yml index 40e9ef469..f50ef5cd2 100644 --- a/.github/workflows/prs.yml +++ b/.github/workflows/jest.yml @@ -1,4 +1,4 @@ -name: PRs +name: Jest Tests on: push: @@ -7,17 +7,15 @@ on: branches: [develop, main] jobs: - lint-and-test: + test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-node@v2 with: cache: npm - node-version: 14 + node-version: 16 - name: Install - run: npm install - - name: Lint - run: npm run lint + run: npm ci - name: Test run: npm run test:coverage -- --maxWorkers=2 diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 000000000..5792ce90f --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,28 @@ +name: Playwright Tests +on: + push: + branches: [develop] + pull_request: + branches: [develop, main] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + cache: npm + node-version: 16 + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright tests + run: npx playwright test + - uses: actions/upload-artifact@v3 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 70e5b31f1..60c884123 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,7 @@ dist/* .idea electron-build/* coverage/* -docs/en/book/* \ No newline at end of file +docs/en/book/* +/test-results/ +/playwright-report/ +/playwright/.cache/ diff --git a/e2e/smoke-test.spec.ts b/e2e/smoke-test.spec.ts new file mode 100644 index 000000000..188c36895 --- /dev/null +++ b/e2e/smoke-test.spec.ts @@ -0,0 +1,148 @@ +import {test, expect, Page} from '@playwright/test'; + +async function skipWelcome(page: Page) { + await page.goto('http://localhost:3000'); + await page.getByRole('button', {name: 'Skip'}).click(); + await page.reload(); +} + +async function createStory(page: Page, name = 'E2E Test Story') { + await skipWelcome(page); + await page.getByRole('tab', {name: 'Story'}).click(); + await page.getByRole('button', {name: 'New'}).click(); + await page + .getByRole('textbox', { + name: 'What should your story be named? You can change this later.' + }) + .type(name); + await page.getByRole('button', {name: 'Create'}).click(); +} + +async function openPassageEditor(page: Page, name: string) { + await page.getByRole('button', {name}).click(); + await expect(page.getByRole('button', {name})).toHaveAttribute( + 'aria-pressed', + 'true' + ); + await page.getByRole('tab', {name: 'Passage'}).click(); + await page.getByRole('button', {name: 'Edit'}).click(); +} + +async function waitForPassageChange() { + // Although the DOM updates when a passage is edited, actually saving changes + // is debounced. This waits long enough for a change to complete. + await new Promise(resolve => setTimeout(resolve, 1100)); +} + +test('Shows welcome screen on first run', async ({page}) => { + await page.goto('http://localhost:3000'); + await expect(page).toHaveTitle('Hi!'); +}); + +test("Doesn't show welcome screen after user finishes it", async ({page}) => { + await skipWelcome(page); + await expect(page).toHaveTitle('0 Stories'); + await page.goto('http://localhost:3000'); + await expect(page).toHaveTitle('0 Stories'); + await page.reload(); + await expect(page).toHaveTitle('0 Stories'); +}); + +test('Can create a story', async ({page}) => { + await createStory(page, 'Create story test'); + await expect(page).toHaveTitle('Create story test'); + + // If these tabs are visible, we're in the story editor. + + await expect(page.getByRole('tab', {name: 'Passage'})).toBeVisible(); + await expect(page.getByRole('tab', {name: 'Story'})).toBeVisible(); + + // Go back to the story list and make sure the story is present there. + + await page.goto('http://localhost:3000'); + await expect(page).toHaveTitle('1 Story'); + await expect(page.getByText('Create story test')).toBeVisible(); + await page.reload(); + await expect(page).toHaveTitle('1 Story'); + await expect(page.getByText('Create story test')).toBeVisible(); +}); + +test('Persists passage edits', async ({page}) => { + await createStory(page, 'Edit passage test'); + await openPassageEditor(page, 'Untitled Passage'); + + // Test different typing speeds to try to shake out any problems with the + // debounced update. + + await page.getByLabel('Passage Text').type('abcdef', {delay: 0}); + await page.getByLabel('Passage Text').type('ghijkl', {delay: 100}); + await page.getByLabel('Passage Text').type('mnopqr', {delay: 250}); + await page.getByLabel('Passage Text').type('stuvwx', {delay: 500}); + await expect(page.getByText('abcdefghijklmnopqrstuvwx')).toBeVisible(); + await waitForPassageChange(); + await page.reload(); + await expect(page.getByText('abcdefghijklmnopqrstuvwx')).toBeVisible(); +}); + +test('Persists passage renames', async ({page}) => { + await createStory(page, 'Edit passage test'); + await page.getByRole('button', {name: 'Untitled Passage'}).click(); + await page.getByRole('button', {name: 'Rename'}).click(); + await page + .getByRole('textbox', { + name: 'What should “Untitled Passage” be renamed to?' + }) + .type('Rename test'); + await page.getByRole('button', {name: 'OK'}).click(); + await expect(page.getByRole('button', {name: 'Rename test'})).toBeVisible(); + await page.reload(); + await expect(page.getByRole('button', {name: 'Rename test'})).toBeVisible(); +}); + +test('Creates a simple story and plays it', async ({context, page}) => { + await createStory(page, 'Publish test'); + await openPassageEditor(page, 'Untitled Passage'); + await page + .getByLabel('Passage Text') + .type('Which way to go? [[Left]] or [[right]]?'); + await page.getByRole('button', {name: 'Close'}).click(); + await openPassageEditor(page, 'Left'); + await page.getByLabel('Passage Text').type('Monsters!'); + await waitForPassageChange(); + await page.getByRole('button', {name: 'Close'}).click(); + await openPassageEditor(page, 'right'); + await page.getByLabel('Passage Text').type('Puppies!'); + await waitForPassageChange(); + await page.getByRole('button', {name: 'Close'}).click(); + await page.getByRole('tab', {name: 'Build'}).click(); + + const [publishedPage] = await Promise.all([ + context.waitForEvent('page'), + page.getByRole('button', {name: 'Play'}).click() + ]); + + // Trying to be as agnostic as possible about Harlowe's DOM structure. Visible + // locators are to distinguish from passage data that's in the DOM but not + // visible. + + await publishedPage.waitForSelector(':visible:text-is("Which way to go?")'); + await publishedPage.locator(':visible:text-is("Left")').click(); + await publishedPage.waitForSelector(':visible:text-is("Monsters!")'); + await expect( + publishedPage.locator(':visible:text-is("Monsters!")') + ).toBeVisible(); + + // Need to close the tab to reset play state. Reloading won't work. + + await publishedPage.close(); + + const [republishedPage] = await Promise.all([ + context.waitForEvent('page'), + page.getByRole('button', {name: 'Play'}).click() + ]); + + await republishedPage.locator(':visible:text-is("right")').click(); + await expect( + republishedPage.locator(':visible:text-is("Puppies!")') + ).toBeVisible(); +}); diff --git a/package-lock.json b/package-lock.json index 0d9e8819a..4397a1ab2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "Twine", - "version": "2.4.1", + "version": "2.5.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "Twine", - "version": "2.4.1", + "version": "2.5.1", "license": "GPL-3.0", "dependencies": { "@popperjs/core": "^2.9.1", @@ -48,6 +48,7 @@ "use-error-boundary": "^2.0.6" }, "devDependencies": { + "@playwright/test": "^1.27.1", "@testing-library/jest-dom": "^5.11.4", "@testing-library/react": "^11.1.0", "@testing-library/react-hooks": "^5.1.1", @@ -2906,6 +2907,22 @@ "node": ">=10" } }, + "node_modules/@playwright/test": { + "version": "1.27.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.27.1.tgz", + "integrity": "sha512-mrL2q0an/7tVqniQQF6RBL2saskjljXzqNcCOVMUjRIgE6Y38nCNaP+Dc2FBW06bcpD3tqIws/HT9qiMHbNU0A==", + "dev": true, + "dependencies": { + "@types/node": "*", + "playwright-core": "1.27.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/@pmmmwh/react-refresh-webpack-plugin": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.4.3.tgz", @@ -20580,6 +20597,18 @@ "node": ">=4" } }, + "node_modules/playwright-core": { + "version": "1.27.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.27.1.tgz", + "integrity": "sha512-9EmeXDncC2Pmp/z+teoVYlvmPWUC6ejSSYZUln7YaP89Z6lpAaiaAnqroUt/BoLo8tn7WYShcfaCh+xofZa44Q==", + "dev": true, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/plist": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/plist/-/plist-3.0.6.tgz", @@ -31545,6 +31574,16 @@ } } }, + "@playwright/test": { + "version": "1.27.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.27.1.tgz", + "integrity": "sha512-mrL2q0an/7tVqniQQF6RBL2saskjljXzqNcCOVMUjRIgE6Y38nCNaP+Dc2FBW06bcpD3tqIws/HT9qiMHbNU0A==", + "dev": true, + "requires": { + "@types/node": "*", + "playwright-core": "1.27.1" + } + }, "@pmmmwh/react-refresh-webpack-plugin": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.4.3.tgz", @@ -45811,6 +45850,12 @@ } } }, + "playwright-core": { + "version": "1.27.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.27.1.tgz", + "integrity": "sha512-9EmeXDncC2Pmp/z+teoVYlvmPWUC6ejSSYZUln7YaP89Z6lpAaiaAnqroUt/BoLo8tn7WYShcfaCh+xofZa44Q==", + "dev": true + }, "plist": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/plist/-/plist-3.0.6.tgz", diff --git a/package.json b/package.json index 195b33d61..5bcf2c976 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "use-error-boundary": "^2.0.6" }, "devDependencies": { + "@playwright/test": "^1.27.1", "@testing-library/jest-dom": "^5.11.4", "@testing-library/react": "^11.1.0", "@testing-library/react-hooks": "^5.1.1", @@ -105,6 +106,7 @@ "build:electron-main": "tsc --project tsconfig.electron.json", "build:electron-bundle": "electron-builder --config electron-builder.config.js --mac --windows --linux --publish=never", "clean": "rimraf dist electron-build", + "e2e": "playwright test", "lint": "eslint src", "start": "react-scripts start", "start:docs": "cd docs/en && mdbook serve", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..002138b87 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,107 @@ +import type {PlaywrightTestConfig} from '@playwright/test'; +import {devices} from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: './e2e', + /* Maximum time one test can run for. */ + timeout: 30 * 1000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 5000 + }, + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://localhost:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry' + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'] + } + }, + + { + name: 'firefox', + use: { + ...devices['Desktop Firefox'] + } + }, + + { + name: 'webkit', + use: { + ...devices['Desktop Safari'] + } + } + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { + // ...devices['Pixel 5'], + // }, + // }, + // { + // name: 'Mobile Safari', + // use: { + // ...devices['iPhone 12'], + // }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { + // channel: 'msedge', + // }, + // }, + // { + // name: 'Google Chrome', + // use: { + // channel: 'chrome', + // }, + // }, + ], + + /* Folder for test artifacts such as screenshots, videos, traces, etc. */ + // outputDir: 'test-results/', + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'npm run start', + port: 3000 + } +}; + +export default config; From 82180dc8ffd0f7cd501e0dd322538cb85da9cd73 Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Sun, 13 Nov 2022 14:55:39 -0500 Subject: [PATCH 22/43] Fix Snowman link --- docs/en/src/getting-started/basic-concepts.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/en/src/getting-started/basic-concepts.md b/docs/en/src/getting-started/basic-concepts.md index 3590e9946..47dc5f3a0 100644 --- a/docs/en/src/getting-started/basic-concepts.md +++ b/docs/en/src/getting-started/basic-concepts.md @@ -86,7 +86,7 @@ community have made. Twine. It offers a lightweight but versatile programming language. As the default, it also has a large community of authors who use it. -- [**Snowman**](https://videlais.github.io/snowman/2/) is a minimal story format +- [**Snowman**](https://videlais.github.io/snowman/) is a minimal story format designed for people who are familiar with web development technologies like CSS and JavaScript, and prioritize customization. From 0ee84ec010925a32e22e756a2c8f97498623d7fa Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Tue, 25 Oct 2022 17:08:17 -0400 Subject: [PATCH 23/43] Add passage fuzzy finder --- docs/en/src/editing-stories/navigating.md | 19 ++ package-lock.json | 14 ++ package.json | 1 + public/locales/en-US.json | 5 + src/components/control/text-input.tsx | 46 ++-- .../__tests__/fuzzy-finder-result.test.tsx | 45 ++++ .../__tests__/fuzzy-finder.test.tsx | 101 ++++++++ .../fuzzy-finder/fuzzy-finder-result.css | 54 ++++ .../fuzzy-finder/fuzzy-finder-result.tsx | 26 ++ src/components/fuzzy-finder/fuzzy-finder.css | 43 ++++ src/components/fuzzy-finder/fuzzy-finder.tsx | 139 +++++++++++ src/components/fuzzy-finder/index.ts | 1 + .../passage-edit/__mocks__/passage-edit.tsx | 5 + .../__tests__/passage-fuzzy-finder.test.tsx | 93 +++++++ .../__tests__/story-edit-route.test.tsx | 5 - .../use-initial-passage-creation.test.tsx | 36 +++ .../use-passage-change-handlers.test.tsx | 231 ++++++++++++++++++ .../__tests__/use-view-center.test.tsx | 106 ++++++++ .../story-edit/passage-fuzzy-finder.tsx | 67 +++++ src/routes/story-edit/story-edit-route.tsx | 178 +++----------- .../__tests__/go-to-passage-button.test.tsx | 32 +++ .../__tests__/passage-actions.test.tsx | 8 + .../toolbar/passage/go-to-passage-button.tsx | 21 ++ .../toolbar/passage/passage-actions.tsx | 9 +- .../story-edit/toolbar/story-edit-toolbar.tsx | 9 +- .../use-initial-passage-creation.ts | 27 ++ .../story-edit/use-passage-change-handlers.ts | 102 ++++++++ src/routes/story-edit/use-view-center.ts | 48 ++++ src/store/stories/__tests__/getters.test.ts | 41 +++- src/store/stories/getters.ts | 24 ++ 30 files changed, 1363 insertions(+), 173 deletions(-) create mode 100644 src/components/fuzzy-finder/__tests__/fuzzy-finder-result.test.tsx create mode 100644 src/components/fuzzy-finder/__tests__/fuzzy-finder.test.tsx create mode 100644 src/components/fuzzy-finder/fuzzy-finder-result.css create mode 100644 src/components/fuzzy-finder/fuzzy-finder-result.tsx create mode 100644 src/components/fuzzy-finder/fuzzy-finder.css create mode 100644 src/components/fuzzy-finder/fuzzy-finder.tsx create mode 100644 src/components/fuzzy-finder/index.ts create mode 100644 src/dialogs/passage-edit/__mocks__/passage-edit.tsx create mode 100644 src/routes/story-edit/__tests__/passage-fuzzy-finder.test.tsx create mode 100644 src/routes/story-edit/__tests__/use-initial-passage-creation.test.tsx create mode 100644 src/routes/story-edit/__tests__/use-passage-change-handlers.test.tsx create mode 100644 src/routes/story-edit/__tests__/use-view-center.test.tsx create mode 100644 src/routes/story-edit/passage-fuzzy-finder.tsx create mode 100644 src/routes/story-edit/toolbar/passage/__tests__/go-to-passage-button.test.tsx create mode 100644 src/routes/story-edit/toolbar/passage/go-to-passage-button.tsx create mode 100644 src/routes/story-edit/use-initial-passage-creation.ts create mode 100644 src/routes/story-edit/use-passage-change-handlers.ts create mode 100644 src/routes/story-edit/use-view-center.ts diff --git a/docs/en/src/editing-stories/navigating.md b/docs/en/src/editing-stories/navigating.md index d001e52a7..07f053c3e 100644 --- a/docs/en/src/editing-stories/navigating.md +++ b/docs/en/src/editing-stories/navigating.md @@ -14,6 +14,25 @@ In one corner of the Story Map, you'll see three buttons showing squares of different sizes. These let you zoom in and out of the map, showing different levels of detail in your passages. +## Jumping to a Passage by Name or Text + +To move to a particular passage in the Story Map screen, choose _Go To_ from the +_Passage_ top toolbar tab, or press the `P` key any time your cursor is not in a +text field. + +This will open a dialog with a search field. Enter either the name of a passage +or some text it contains, and a list of matching passages will appear. Twine +uses fuzzy matching, so you don't have to enter the passage name exactly, and it +will find close matches if you make a typo. When deciding which pasages match +what you've typed, it slightly prefers matches in a passage name to what's in +passage text. + +Click a passage in the list or press the Return key to select the one which has +two chevrons (») beside it to move your view so that the passage you've chosen +is centered. The chosen passage will also be selected. Use the up and down arrow +keys on your keyboard to move the chevrons in the list to another passage in the +list of matches. + ## Empty Passages An empty passage is one you haven't written any text in (usually). These show up diff --git a/package-lock.json b/package-lock.json index 4397a1ab2..44e05657d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "focus-trap-react": "^8.9.2", "focus-visible": "^5.2.0", "fs-extra": "^10.0.0", + "fuse.js": "^6.6.2", "i18next": "^19.9.2", "i18next-http-backend": "^1.1.1", "is-absolute-url": "^3.0.3", @@ -13409,6 +13410,14 @@ "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", "dev": true }, + "node_modules/fuse.js": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.6.2.tgz", + "integrity": "sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==", + "engines": { + "node": ">=10" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -40215,6 +40224,11 @@ "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", "dev": true }, + "fuse.js": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.6.2.tgz", + "integrity": "sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==" + }, "gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", diff --git a/package.json b/package.json index 5bcf2c976..efa841301 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "focus-trap-react": "^8.9.2", "focus-visible": "^5.2.0", "fs-extra": "^10.0.0", + "fuse.js": "^6.6.2", "i18next": "^19.9.2", "i18next-http-backend": "^1.1.1", "is-absolute-url": "^3.0.3", diff --git a/public/locales/en-US.json b/public/locales/en-US.json index 0fa96150f..35e7b31cf 100644 --- a/public/locales/en-US.json +++ b/public/locales/en-US.json @@ -91,6 +91,10 @@ "placeholderClick": "Double-click this passage to edit it.", "placeholderTouch": "Tap this passage, then choose Edit from the Passage tab to edit it." }, + "passageFuzzyFinder": { + "noResults": "No matches.", + "prompt": "Search by passage name or text" + }, "renamePassageButton": { "emptyName": "Please enter a name.", "nameAlreadyUsed": "Another passage in this story has this name." @@ -278,6 +282,7 @@ "storyEdit": { "toolbar": { "findAndReplace": "Find and Replace", + "goTo": "Go To", "javaScript": "JavaScript", "passageTags": "Passage Tags", "snapToGrid": "Snap to Grid", diff --git a/src/components/control/text-input.tsx b/src/components/control/text-input.tsx index 217a5b3ea..4e6df6fcd 100644 --- a/src/components/control/text-input.tsx +++ b/src/components/control/text-input.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import './text-input.css'; export interface TextInputProps { + children: React.ReactNode; onChange?: (event: React.ChangeEvent) => void; onInput?: (event: React.FormEvent) => void; orientation?: 'horizontal' | 'vertical'; @@ -11,25 +12,28 @@ export interface TextInputProps { value: string; } -export const TextInput: React.FC = props => { - const className = classNames( - 'text-input', - `orientation-${props.orientation}`, - `type-${props.type}` - ); +export const TextInput = React.forwardRef( + (props, ref) => { + const className = classNames( + 'text-input', + `orientation-${props.orientation}`, + `type-${props.type}` + ); - return ( - - - - ); -}; + return ( + + + + ); + } +); diff --git a/src/components/fuzzy-finder/__tests__/fuzzy-finder-result.test.tsx b/src/components/fuzzy-finder/__tests__/fuzzy-finder-result.test.tsx new file mode 100644 index 000000000..f6c85542f --- /dev/null +++ b/src/components/fuzzy-finder/__tests__/fuzzy-finder-result.test.tsx @@ -0,0 +1,45 @@ +import {fireEvent, render, screen} from '@testing-library/react'; +import {axe} from 'jest-axe'; +import * as React from 'react'; +import { + FuzzyFinderResult, + FuzzyFinderResultProps +} from '../fuzzy-finder-result'; + +describe('FuzzyFinderResult', () => { + function renderComponent(props?: Partial) { + return render( + + ); + } + + it('displays the heading', () => { + renderComponent({heading: 'test-heading'}); + expect(screen.getByText('test-heading')).toBeInTheDocument(); + }); + + it('displays the detail', () => { + renderComponent({detail: 'test-detail'}); + expect(screen.getByText('test-detail')).toBeInTheDocument(); + }); + + it('calls the onClick prop when the button is clicked', () => { + const onClick = jest.fn(); + + renderComponent({onClick}); + expect(onClick).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button')); + expect(onClick).toBeCalledTimes(1); + }); + + it('is accessible', async () => { + const {container} = renderComponent(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/src/components/fuzzy-finder/__tests__/fuzzy-finder.test.tsx b/src/components/fuzzy-finder/__tests__/fuzzy-finder.test.tsx new file mode 100644 index 000000000..d91115449 --- /dev/null +++ b/src/components/fuzzy-finder/__tests__/fuzzy-finder.test.tsx @@ -0,0 +1,101 @@ +import {fireEvent, render, screen} from '@testing-library/react'; +import {axe} from 'jest-axe'; +import * as React from 'react'; +import {FuzzyFinder, FuzzyFinderProps} from '../fuzzy-finder'; + +describe('FuzzyFinder', () => { + function renderComponent(props?: Partial) { + return render( + + ); + } + + it('displays a prompt', () => { + renderComponent({prompt: 'test-prompt'}); + expect(screen.getByText('test-prompt')).toBeInTheDocument(); + }); + + it('displays a text field with the search prop as value', () => { + renderComponent({search: 'test-search'}); + expect(screen.getByRole('textbox')).toHaveValue('test-search'); + }); + + // jsdom doesn't seem to implement focus in a way that works for these tests. + it.todo('focuses the text field when initially mounted'); + it.todo('provides keyboard shortcuts'); + + it('calls the onChangeSearch prop when the text field is changed', () => { + const onChangeSearch = jest.fn(); + + renderComponent({onChangeSearch}); + expect(onChangeSearch).not.toBeCalled(); + fireEvent.change(screen.getByRole('textbox'), { + target: {value: 'test-change'} + }); + expect(onChangeSearch.mock.calls).toEqual([['test-change']]); + }); + + it('displays a close button which calls the onClose prop', () => { + const onClose = jest.fn(); + + renderComponent({onClose}); + expect(onClose).not.toBeCalled(); + fireEvent.click(screen.getByRole('button', {name: 'Close'})); + expect(onClose).toBeCalledTimes(1); + }); + + it('displays a result for every entry in the results prop', () => { + renderComponent({ + results: [ + {detail: 'test-detail-1', heading: 'test-heading-1'}, + {detail: 'test-detail-2', heading: 'test-heading-2'} + ] + }); + + expect(screen.getByText('test-detail-1')).toBeInTheDocument(); + expect(screen.getByText('test-detail-2')).toBeInTheDocument(); + }); + + it('displays the noResultsText prop if there are no results', () => { + renderComponent({ + noResultsText: 'test-no-results', + results: [] + }); + expect(screen.getByText('test-no-results')).toBeInTheDocument(); + }); + + it("doesn't display the noResultsText prop if there are results", () => { + renderComponent({ + noResultsText: 'test-no-results', + results: [{detail: 'test-detail-1', heading: 'test-heading-1'}] + }); + expect(screen.queryByText('test-no-results')).not.toBeInTheDocument(); + }); + + it('calls the onSelectResult prop with the array index when a result is clicked', () => { + const onSelectResult = jest.fn(); + + renderComponent({ + onSelectResult, + results: [{detail: 'test-detail-1', heading: 'test-heading-1'}] + }); + expect(onSelectResult).not.toBeCalled(); + fireEvent.click(screen.getByText('test-detail-1')); + expect(onSelectResult.mock.calls).toEqual([[0]]); + }); + + it('is accessible', async () => { + const {container} = renderComponent(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/src/components/fuzzy-finder/fuzzy-finder-result.css b/src/components/fuzzy-finder/fuzzy-finder-result.css new file mode 100644 index 000000000..56d379782 --- /dev/null +++ b/src/components/fuzzy-finder/fuzzy-finder-result.css @@ -0,0 +1,54 @@ +@import '../../styles/colors.css'; +@import '../../styles/metrics.css'; +@import '../../styles/typography.css'; + +.fuzzy-finder-result { + appearance: none; + align-items: center; + background: none; + border: none; + border-radius: var(--corner-round); + color: var(--dark-gray); + cursor: pointer; + display: flex; + flex-direction: row; + font: 100% var(--font-system); + gap: var(--grid-size); + padding: var(--grid-size); + transition: background 0.3s; + user-select: none; +} + +.fuzzy-finder-result svg { + color: var(--dark-blue); + flex-shrink: 0; + height: 16px; + margin-right: calc(-0.5 * var(--grid-size)); + visibility: hidden; + width: 16px; +} + +.fuzzy-finder-result:hover { + background-color: var(--light-gray-translucent); +} + +.fuzzy-finder-result .heading { + color: var(--black); + flex-shrink: 0; +} + +.fuzzy-finder-result .detail { + color: var(--gray); + overflow: hidden; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fuzzy-finder-result.selected svg { + visibility: visible; +} + +.fuzzy-finder-result.selected .heading { + color: var(--dark-blue); +} diff --git a/src/components/fuzzy-finder/fuzzy-finder-result.tsx b/src/components/fuzzy-finder/fuzzy-finder-result.tsx new file mode 100644 index 000000000..418f1e639 --- /dev/null +++ b/src/components/fuzzy-finder/fuzzy-finder-result.tsx @@ -0,0 +1,26 @@ +import {IconChevronsRight} from '@tabler/icons'; +import classNames from 'classnames'; +import * as React from 'react'; +import './fuzzy-finder-result.css'; + +export interface FuzzyFinderResultProps { + detail: string; + heading: string; + onClick: () => void; + selected?: boolean; +} + +export const FuzzyFinderResult: React.FC = props => { + const {detail, heading, onClick, selected} = props; + + return ( + + ); +}; diff --git a/src/components/fuzzy-finder/fuzzy-finder.css b/src/components/fuzzy-finder/fuzzy-finder.css new file mode 100644 index 000000000..19a69e2d5 --- /dev/null +++ b/src/components/fuzzy-finder/fuzzy-finder.css @@ -0,0 +1,43 @@ +@import '../../styles/depth.css'; +@import '../../styles/metrics.css'; + +.fuzzy-finder { + box-shadow: var(--shadow-large); + left: var(--grid-size); + position: fixed; + top: calc(2 * var(--control-height) + var(--grid-size)); /* below height */ + z-index: 10; + width: 500px; +} + +.fuzzy-finder .search { + align-items: center; + display: flex; + padding: var(--control-inner-padding) 0 var(--control-inner-padding) var(--grid-size); +} + +.fuzzy-finder .search .text-input { + flex-grow: 1; +} + +.fuzzy-finder .search .text-input label { + width: 100%; +} + +.fuzzy-finder .results ol { + display: flex; + flex-direction: column; + list-style: none; + margin: 0; + padding: 0; +} + +.fuzzy-finder .search + .results.has-results { + margin-top: calc(-1 * var(--control-inner-padding)); +} + + +.fuzzy-finder .no-results { + font-style: italic; + padding: 0 var(--grid-size) var(--grid-size) var(--grid-size); +} \ No newline at end of file diff --git a/src/components/fuzzy-finder/fuzzy-finder.tsx b/src/components/fuzzy-finder/fuzzy-finder.tsx new file mode 100644 index 000000000..b4fa674ed --- /dev/null +++ b/src/components/fuzzy-finder/fuzzy-finder.tsx @@ -0,0 +1,139 @@ +import {IconX} from '@tabler/icons'; +import classnames from 'classnames'; +import * as React from 'react'; +import {useHotkeys} from 'react-hotkeys-hook'; +import {Card} from '../container/card'; +import {IconButton} from '../control/icon-button'; +import {TextInput} from '../control/text-input'; +import {FuzzyFinderResult, FuzzyFinderResultProps} from './fuzzy-finder-result'; +import './fuzzy-finder.css'; + +function elementIsFocused(element: HTMLElement | null): boolean { + return !!(element && document.activeElement === element); +} + +export interface FuzzyFinderProps { + noResultsText: string; + onChangeSearch: (value: string) => void; + onClose: () => void; + onSelectResult: (index: number) => void; + prompt: string; + results: Omit[]; + search: string; +} + +export const FuzzyFinder: React.FC = props => { + const { + noResultsText, + onChangeSearch, + onClose, + onSelectResult, + prompt, + search, + results + } = props; + const [selectedResult, setSelectedResult] = React.useState(0); + const containerRef = React.useRef(null); + const inputRef = React.useRef(null); + useHotkeys( + 'escape', + onClose, + { + enableOnTags: ['INPUT'], + filter: () => elementIsFocused(inputRef.current) + }, + [onClose] + ); + useHotkeys( + 'return', + () => onSelectResult(selectedResult), + { + enableOnTags: ['INPUT'], + filter: () => elementIsFocused(inputRef.current) + }, + [onSelectResult, selectedResult] + ); + useHotkeys( + 'up', + () => + setSelectedResult(value => + value === 0 ? results.length - 1 : value - 1 + ), + { + enableOnTags: ['INPUT'], + filter: () => elementIsFocused(inputRef.current) + }, + [onSelectResult, selectedResult] + ); + useHotkeys( + 'down', + () => + setSelectedResult(value => + value === results.length - 1 ? 0 : value + 1 + ), + { + enableOnTags: ['INPUT'], + filter: () => elementIsFocused(inputRef.current) + }, + [onSelectResult, selectedResult] + ); + + React.useEffect(() => { + // Automatically focus the search input on mount. + // + // This timeout is needed to avoid stealing focus too early. If this + // component is mounted in reaction to a hotkey, the input will receive the + // hotkey input. + + const timeout = window.setTimeout(() => { + if (containerRef.current) { + containerRef.current.querySelector('input')?.focus(); + } + }, 0); + + return () => window.clearTimeout(timeout); + }, []); + + return ( +

    + +
    + onChangeSearch(event.target.value)} + ref={inputRef} + value={search} + > + {prompt} + + } + iconOnly + label="Close" + onClick={onClose} + tooltipPosition="bottom" + /> +
    +
    0})} + > + {search.length > 0 && results.length === 0 && ( +
    {noResultsText}
    + )} + {search.length > 0 && results.length > 0 && ( +
      + {results.map((props, index) => ( +
    1. + onSelectResult(index)} + selected={index === selectedResult} + /> +
    2. + ))} +
    + )} +
    +
    +
    + ); +}; diff --git a/src/components/fuzzy-finder/index.ts b/src/components/fuzzy-finder/index.ts new file mode 100644 index 000000000..ee43a53fc --- /dev/null +++ b/src/components/fuzzy-finder/index.ts @@ -0,0 +1 @@ +export * from './fuzzy-finder'; diff --git a/src/dialogs/passage-edit/__mocks__/passage-edit.tsx b/src/dialogs/passage-edit/__mocks__/passage-edit.tsx new file mode 100644 index 000000000..449dbb113 --- /dev/null +++ b/src/dialogs/passage-edit/__mocks__/passage-edit.tsx @@ -0,0 +1,5 @@ +import * as React from 'react'; + +export const PassageEditDialog = ({passageId}: {passageId: string}) => ( +
    +); diff --git a/src/routes/story-edit/__tests__/passage-fuzzy-finder.test.tsx b/src/routes/story-edit/__tests__/passage-fuzzy-finder.test.tsx new file mode 100644 index 000000000..f9731590b --- /dev/null +++ b/src/routes/story-edit/__tests__/passage-fuzzy-finder.test.tsx @@ -0,0 +1,93 @@ +import {fireEvent, render, screen} from '@testing-library/react'; +import {axe} from 'jest-axe'; +import {FakeStateProvider, fakeStory, StoryInspector} from '../../../test-util'; +import { + PassageFuzzyFinder, + PassageFuzzyFinderProps +} from '../passage-fuzzy-finder'; + +describe('PassageFuzzyFinder', () => { + function renderComponent( + props?: Partial, + story = fakeStory() + ) { + return render( + + + + + ); + } + + describe('When closed', () => { + // Can't figure out how to trigger this in jsdom. + + it.todo('calls the onOpen prop when the P key is pressed'); + }); + + describe('When open', () => { + it('calls the onClose prop when the fuzzy finder is closed', () => { + const onClose = jest.fn(); + + renderComponent({onClose}); + expect(onClose).not.toBeCalled(); + fireEvent.click(screen.getByRole('button', {name: 'Close'})); + expect(onClose).toBeCalledTimes(1); + }); + + it('updates results based on what the user enters', () => { + const story = fakeStory(1); + + story.passages[0].name = 'a name'; + story.passages[0].text = 'text'; + renderComponent({}, story); + fireEvent.change(screen.getByRole('textbox'), {target: {value: 'a'}}); + expect( + screen.getByRole('button', {name: 'a name text'}) + ).toBeInTheDocument(); + }); + + describe('When a result is selected', () => { + it('centers the view on a passage and selects it when a result is selected', () => { + const setCenter = jest.fn(); + const story = fakeStory(1); + + story.passages[0].name = 'a name'; + story.passages[0].selected = false; + story.passages[0].text = 'text'; + renderComponent({setCenter}, story); + fireEvent.change(screen.getByRole('textbox'), {target: {value: 'a'}}); + expect(setCenter).not.toBeCalled(); + fireEvent.click(screen.getByRole('button', {name: 'a name text'})); + expect(setCenter.mock.calls).toEqual([[story.passages[0]]]); + }); + + it('calls the onClose prop', () => { + const onClose = jest.fn(); + const story = fakeStory(1); + + story.passages[0].name = 'a name'; + story.passages[0].selected = false; + story.passages[0].text = 'text'; + renderComponent({onClose}, story); + fireEvent.change(screen.getByRole('textbox'), {target: {value: 'a'}}); + expect(onClose).not.toBeCalled(); + fireEvent.click(screen.getByRole('button', {name: 'a name text'})); + expect(onClose).toBeCalledTimes(1); + }); + }); + }); + + it('is accessible', async () => { + const {container} = renderComponent(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/src/routes/story-edit/__tests__/story-edit-route.test.tsx b/src/routes/story-edit/__tests__/story-edit-route.test.tsx index 5d9d83ece..7567de66a 100644 --- a/src/routes/story-edit/__tests__/story-edit-route.test.tsx +++ b/src/routes/story-edit/__tests__/story-edit-route.test.tsx @@ -98,11 +98,6 @@ describe('', () => { ).toBeInTheDocument(); }); - it('creates a passage automatically if the story has none', async () => { - await renderComponent(fakeStory(0)); - expect(screen.getAllByTestId(/^passage-/).length).toBe(1); - }); - it('sets up zoom keyboard shortcuts', async () => { await renderComponent(fakeStory()); expect(useZoomShortcutsMock).toBeCalled(); diff --git a/src/routes/story-edit/__tests__/use-initial-passage-creation.test.tsx b/src/routes/story-edit/__tests__/use-initial-passage-creation.test.tsx new file mode 100644 index 000000000..e867f8e6f --- /dev/null +++ b/src/routes/story-edit/__tests__/use-initial-passage-creation.test.tsx @@ -0,0 +1,36 @@ +import {render, screen} from '@testing-library/react'; +import {Story, useStoriesContext} from '../../../store/stories'; +import {fakeStory, FakeStateProvider, StoryInspector} from '../../../test-util'; +import {useInitialPassageCreation} from '../use-initial-passage-creation'; + +function TestComponent() { + const {stories} = useStoriesContext(); + useInitialPassageCreation(stories[0], () => ({left: 20, top: 50})); + return null; +} + +describe('useInitialPassageCreation', () => { + function renderHook(story: Story) { + return render( + + + + + ); + } + + it('creates a passage automatically if the story has none', () => { + const story = fakeStory(0); + + renderHook(story); + expect(screen.getByTestId('passage', {exact: false})).toBeInTheDocument(); + expect(screen.getAllByTestId('passage', {exact: false}).length).toBe(1); + }); + + it("doesn't create an additional passage if the story already has one", () => { + const story = fakeStory(1); + + renderHook(story); + expect(screen.getAllByTestId('passage', {exact: false}).length).toBe(1); + }); +}); diff --git a/src/routes/story-edit/__tests__/use-passage-change-handlers.test.tsx b/src/routes/story-edit/__tests__/use-passage-change-handlers.test.tsx new file mode 100644 index 000000000..cfcab23d6 --- /dev/null +++ b/src/routes/story-edit/__tests__/use-passage-change-handlers.test.tsx @@ -0,0 +1,231 @@ +import {fireEvent, render, screen, waitFor} from '@testing-library/react'; +import {Story, useStoriesContext} from '../../../store/stories'; +import {fakeStory, FakeStateProvider, StoryInspector} from '../../../test-util'; +import {usePassageChangeHandlers} from '../use-passage-change-handlers'; + +jest.mock('../../../dialogs/passage-edit/passage-edit'); + +function TestComponent() { + const {stories} = useStoriesContext(); + const { + handleDeselectPassage, + handleDragPassages, + handleEditPassage, + handleSelectPassage, + handleSelectRect + } = usePassageChangeHandlers(stories[0]); + return ( + <> + + + + + + + + + + ); +} + +describe('usePassageChangeHandlers', () => { + function renderHook(story: Story) { + return render( + + + + + ); + } + + // These test basic functionality. Comprehensive tests happen in store/. + + it('returns a handleDeselectPassage function which deselects a passage', () => { + const story = fakeStory(2); + + story.passages[0].selected = true; + story.passages[1].selected = true; + renderHook(story); + expect( + screen.getByTestId(`passage-${story.passages[0].id}`).dataset.selected + ).toBe('true'); + expect( + screen.getByTestId(`passage-${story.passages[1].id}`).dataset.selected + ).toBe('true'); + fireEvent.click(screen.getByText('handleDeselectPassage')); + expect( + screen.getByTestId(`passage-${story.passages[0].id}`).dataset.selected + ).toBe('false'); + expect( + screen.getByTestId(`passage-${story.passages[1].id}`).dataset.selected + ).toBe('true'); + }); + + describe('The handleDragPassages function it returns', () => { + it('moves selected passages', () => { + const story = fakeStory(2); + + story.passages[0].left = 10; + story.passages[0].selected = true; + story.passages[0].top = 20; + story.passages[1].selected = false; + story.zoom = 1; + renderHook(story); + fireEvent.click(screen.getByText('handleDragPassages')); + + const passage0 = screen.getByTestId(`passage-${story.passages[0].id}`); + const passage1 = screen.getByTestId(`passage-${story.passages[1].id}`); + + expect(passage0.dataset.left).toBe('30'); + expect(passage0.dataset.top).toBe('30'); + expect(passage1.dataset.left).toBe(story.passages[1].left.toString()); + expect(passage1.dataset.top).toBe(story.passages[1].top.toString()); + }); + + it('ignores drags of less than a pixel', () => { + const story = fakeStory(1); + + story.passages[0].left = 10; + story.passages[0].selected = true; + story.passages[0].top = 20; + renderHook(story); + fireEvent.click(screen.getByText('handleDragPassages 0')); + + const passage0 = screen.getByTestId(`passage-${story.passages[0].id}`); + + expect(passage0.dataset.left).toBe('10'); + expect(passage0.dataset.top).toBe('20'); + }); + }); + + it('returns a handleEditPassage function which opens a passage editor', () => { + const story = fakeStory(1); + + renderHook(story); + fireEvent.click(screen.getByText('handleEditPassage')); + + const editDialog = screen.getByTestId('mock-passage-edit-dialog'); + + expect(editDialog).toBeInTheDocument(); + expect(editDialog.dataset.passageId).toBe(story.passages[0].id); + }); + + describe('The handleSelectPassage function it returns', () => { + let story: Story; + + beforeEach(() => { + story = fakeStory(2); + story.passages[0].selected = false; + story.passages[1].selected = true; + renderHook(story); + }); + + it('selects passages non-exclusively if called with a false argument', () => { + expect( + screen.getByTestId(`passage-${story.passages[0].id}`).dataset.selected + ).toBe('false'); + expect( + screen.getByTestId(`passage-${story.passages[1].id}`).dataset.selected + ).toBe('true'); + fireEvent.click(screen.getByText('handleSelectPassage')); + expect( + screen.getByTestId(`passage-${story.passages[0].id}`).dataset.selected + ).toBe('true'); + expect( + screen.getByTestId(`passage-${story.passages[1].id}`).dataset.selected + ).toBe('true'); + }); + + it('selects passages exclusively if called with a true argument', () => { + expect( + screen.getByTestId(`passage-${story.passages[0].id}`).dataset.selected + ).toBe('false'); + expect( + screen.getByTestId(`passage-${story.passages[1].id}`).dataset.selected + ).toBe('true'); + fireEvent.click(screen.getByText('handleSelectPassage exclusive')); + expect( + screen.getByTestId(`passage-${story.passages[0].id}`).dataset.selected + ).toBe('true'); + expect( + screen.getByTestId(`passage-${story.passages[1].id}`).dataset.selected + ).toBe('false'); + }); + }); + + describe('The handleSelectRect function it returns', () => { + let story: Story; + + beforeEach(() => { + story = fakeStory(2); + story.passages[0].left = 50; + story.passages[0].selected = false; + story.passages[0].top = 50; + story.passages[1].left = 1000; + story.passages[1].selected = true; + story.passages[1].top = 1000; + story.zoom = 1; + renderHook(story); + }); + + it('selects passages exclusively if called with a false argument', async () => { + expect( + screen.getByTestId(`passage-${story.passages[0].id}`).dataset.selected + ).toBe('false'); + expect( + screen.getByTestId(`passage-${story.passages[1].id}`).dataset.selected + ).toBe('true'); + fireEvent.click(screen.getByText('handleSelectRect')); + await waitFor(() => + expect( + screen.getByTestId(`passage-${story.passages[0].id}`).dataset.selected + ).toBe('true') + ); + expect( + screen.getByTestId(`passage-${story.passages[1].id}`).dataset.selected + ).toBe('false'); + }); + + it('selects passages non-exclusively if called with a true argument', () => { + expect( + screen.getByTestId(`passage-${story.passages[0].id}`).dataset.selected + ).toBe('false'); + expect( + screen.getByTestId(`passage-${story.passages[1].id}`).dataset.selected + ).toBe('true'); + fireEvent.click(screen.getByText('handleSelectRect additive')); + expect( + screen.getByTestId(`passage-${story.passages[0].id}`).dataset.selected + ).toBe('true'); + expect( + screen.getByTestId(`passage-${story.passages[1].id}`).dataset.selected + ).toBe('true'); + }); + }); +}); diff --git a/src/routes/story-edit/__tests__/use-view-center.test.tsx b/src/routes/story-edit/__tests__/use-view-center.test.tsx new file mode 100644 index 000000000..ce9567bb9 --- /dev/null +++ b/src/routes/story-edit/__tests__/use-view-center.test.tsx @@ -0,0 +1,106 @@ +import {renderHook} from '@testing-library/react-hooks'; +import {random} from 'faker'; +import {DialogsContext, DialogsContextProvider} from '../../../dialogs'; +import {Story} from '../../../store/stories'; +import {FakeStateProvider, fakeStory} from '../../../test-util'; +import {useViewCenter} from '../use-view-center'; + +describe('useViewCenter', () => { + let story: Story; + let el: any; + + beforeEach(() => { + story = fakeStory(); + el = { + clientHeight: random.number(), + clientWidth: random.number(), + getBoundingClientRect: () => ({ + height: 100, + left: 20, + top: 10, + width: 200 + }), + scrollLeft: random.number(), + scrollTo: jest.fn(), + scrollTop: random.number() + }; + }); + + describe('the getCenter() function it returns', () => { + it('returns the center of a DOM element, adjusted for the story zoom', () => { + const {result} = renderHook(() => useViewCenter(story, el as any)); + + expect(result.current.getCenter()).toEqual({ + left: (el.scrollLeft + el.clientWidth / 2) / story.zoom, + top: (el.scrollTop + el.clientHeight / 2) / story.zoom + }); + }); + + it('throws an error if the DOM element passed is null', () => { + const {result} = renderHook(() => useViewCenter(story, null)); + + expect(result.current.getCenter).toThrow(); + }); + }); + + describe('the setCenter() function it returns', () => { + it('scrolls the element to center a position, adjusted for the story zoom', () => { + const {result} = renderHook(() => useViewCenter(story, el as any)); + const left = random.number(); + const top = random.number(); + + result.current.setCenter({left, top}); + expect(el.scrollTo.mock.calls).toEqual([ + [ + { + left: (left - 200 / story.zoom / 2) * story.zoom, + top: (top - 100 / story.zoom / 2) * story.zoom + } + ] + ]); + }); + + it('adjusts the center if dialogs are open', () => { + const dialogWidth = random.number(); + const {result} = renderHook(() => useViewCenter(story, el as any), { + wrapper: ({children}) => ( + + null, + highlighted: false, + maximized: false + } + ] + }} + > + {children} + + + ) + }); + const left = random.number(); + const top = random.number(); + + result.current.setCenter({left, top}); + expect(el.scrollTo.mock.calls).toEqual([ + [ + { + left: (left - 200 / story.zoom / 2) * story.zoom + dialogWidth / 2, + top: (top - 100 / story.zoom / 2) * story.zoom + } + ] + ]); + }); + + it('throws an error if the element is null', () => { + const {result} = renderHook(() => useViewCenter(story, null)); + + expect(result.current.setCenter).toThrow(); + }); + }); +}); diff --git a/src/routes/story-edit/passage-fuzzy-finder.tsx b/src/routes/story-edit/passage-fuzzy-finder.tsx new file mode 100644 index 000000000..93323ff2f --- /dev/null +++ b/src/routes/story-edit/passage-fuzzy-finder.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import {useHotkeys} from 'react-hotkeys-hook'; +import {useTranslation} from 'react-i18next'; +import {CSSTransition} from 'react-transition-group'; +import {FuzzyFinder} from '../../components/fuzzy-finder'; +import { + passagesMatchingFuzzySearch, + selectPassage, + Story, + useStoriesContext +} from '../../store/stories'; +import {Point} from '../../util/geometry'; + +export interface PassageFuzzyFinderProps { + onClose: () => void; + onOpen: () => void; + open?: boolean; + setCenter: (value: Point) => void; + story: Story; +} + +export const PassageFuzzyFinder: React.FC = props => { + const {onClose, onOpen, open, setCenter, story} = props; + const {dispatch} = useStoriesContext(); + const [search, setSearch] = React.useState(''); + const matches = React.useMemo( + () => passagesMatchingFuzzySearch(story.passages, search), + [search, story.passages] + ); + const results = React.useMemo( + () => + matches.map(({name, text}) => ({ + heading: name, + detail: text + })), + [matches] + ); + useHotkeys('p', onOpen); + const {t} = useTranslation(); + + function handleSelectResult(index: number) { + setCenter(matches[index]); + dispatch(selectPassage(story, matches[index], true)); + setSearch(''); + onClose(); + } + + return ( + + + + ); +}; diff --git a/src/routes/story-edit/story-edit-route.tsx b/src/routes/story-edit/story-edit-route.tsx index ee6405f94..bdaad5733 100644 --- a/src/routes/story-edit/story-edit-route.tsx +++ b/src/routes/story-edit/story-edit-route.tsx @@ -1,160 +1,51 @@ import * as React from 'react'; import {useParams} from 'react-router-dom'; import {MainContent} from '../../components/container/main-content'; -import { - DialogsContextProvider, - PassageEditDialog, - useDialogsContext -} from '../../dialogs'; -import { - createUntitledPassage, - deselectPassage, - movePassages, - Passage, - selectPassage, - selectPassagesInRect, - storyWithId -} from '../../store/stories'; +import {DocumentTitle} from '../../components/document-title/document-title'; +import {DialogsContextProvider} from '../../dialogs'; +import {storyWithId} from '../../store/stories'; import { UndoableStoriesContextProvider, useUndoableStoriesContext } from '../../store/undoable-stories'; -import {Point, Rect} from '../../util/geometry'; +import {MarqueeablePassageMap} from './marqueeable-passage-map'; import {StoryEditToolbar} from './toolbar'; -import './story-edit-route.css'; -import {ZoomButtons} from './zoom-buttons'; -import {DocumentTitle} from '../../components/document-title/document-title'; -import {useZoomTransition} from './use-zoom-transition'; +import {useInitialPassageCreation} from './use-initial-passage-creation'; +import {usePassageChangeHandlers} from './use-passage-change-handlers'; +import {useViewCenter} from './use-view-center'; import {useZoomShortcuts} from './use-zoom-shortcuts'; -import {MarqueeablePassageMap} from './marqueeable-passage-map'; +import {useZoomTransition} from './use-zoom-transition'; +import {ZoomButtons} from './zoom-buttons'; +import './story-edit-route.css'; +import {PassageFuzzyFinder} from './passage-fuzzy-finder'; export const InnerStoryEditRoute: React.FC = () => { - const [inited, setInited] = React.useState(false); - const {dispatch: dialogsDispatch} = useDialogsContext(); - const mainContent = React.useRef(null); const {storyId} = useParams<{storyId: string}>(); - const {dispatch: undoableStoriesDispatch, stories} = - useUndoableStoriesContext(); + const {stories} = useUndoableStoriesContext(); const story = storyWithId(stories, storyId); - useZoomShortcuts(story); - - const selectedPassages = React.useMemo( - () => story.passages.filter(passage => passage.selected), - [story.passages] - ); - - const getCenter = React.useCallback(() => { - if (!mainContent.current) { - throw new Error( - 'Asked for the center of the main content, but it does not exist in the DOM yet' - ); - } - - return { - left: - (mainContent.current.scrollLeft + mainContent.current.clientWidth / 2) / - story.zoom, - top: - (mainContent.current.scrollTop + mainContent.current.clientHeight / 2) / - story.zoom - }; - }, [story.zoom]); - - const handleDeselectPassage = React.useCallback( - (passage: Passage) => - undoableStoriesDispatch(deselectPassage(story, passage)), - [story, undoableStoriesDispatch] - ); - - const handleDragPassages = React.useCallback( - (change: Point) => { - // Ignore tiny drags--they're probably caused by the user moving their - // mouse slightly during double-clicking. - - if (Math.abs(change.left) < 1 && Math.abs(change.top) < 1) { - return; - } - - undoableStoriesDispatch( - movePassages( - story, - story.passages.reduce( - (result, current) => - current.selected ? [...result, current.id] : result, - [] - ), - change.left / story.zoom, - change.top / story.zoom - ), - selectedPassages.length > 1 - ? 'undoChange.movePassages' - : 'undoChange.movePassages' - ); - }, - [selectedPassages.length, story, undoableStoriesDispatch] - ); - - const handleEditPassage = React.useCallback( - (passage: Passage) => - dialogsDispatch({ - type: 'addDialog', - component: PassageEditDialog, - props: {passageId: passage.id, storyId: story.id} - }), - [dialogsDispatch, story.id] - ); - - const handleSelectPassage = React.useCallback( - (passage: Passage, exclusive: boolean) => - undoableStoriesDispatch(selectPassage(story, passage, exclusive)), - [story, undoableStoriesDispatch] - ); - - const handleSelectRect = React.useCallback( - (rect: Rect, exclusive: boolean) => { - // The rect we receive is in screen coordinates--we need to convert to - // logical ones. - const logicalRect: Rect = { - height: rect.height / story.zoom, - left: rect.left / story.zoom, - top: rect.top / story.zoom, - width: rect.width / story.zoom - }; - // This should not be undoable. - undoableStoriesDispatch( - selectPassagesInRect( - story, - logicalRect, - exclusive ? selectedPassages.map(passage => passage.id) : [] - ) - ); - }, - [selectedPassages, story, undoableStoriesDispatch] - ); - - // If we have just mounted and the story has no passages, create one for the - // user (and skip undo history, since it was an automatic action). - - React.useEffect(() => { - if (!inited) { - setInited(true); - - if (story.passages.length === 0) { - const center = getCenter(); - - undoableStoriesDispatch( - createUntitledPassage(story, center.left, center.top) - ); - } - } - }, [getCenter, inited, story, undoableStoriesDispatch]); - + const [fuzzyFinderOpen, setFuzzyFinderOpen] = React.useState(false); + const mainContent = React.useRef(null); + const {getCenter, setCenter} = useViewCenter(story, mainContent.current); + const { + handleDeselectPassage, + handleDragPassages, + handleEditPassage, + handleSelectPassage, + handleSelectRect + } = usePassageChangeHandlers(story); const visibleZoom = useZoomTransition(story.zoom, mainContent.current); + useZoomShortcuts(story); + useInitialPassageCreation(story, getCenter); + return (
    - + setFuzzyFinderOpen(true)} + story={story} + /> { visibleZoom={visibleZoom} zoom={story.zoom} /> + setFuzzyFinderOpen(false)} + onOpen={() => setFuzzyFinderOpen(true)} + open={fuzzyFinderOpen} + setCenter={setCenter} + story={story} + />
    @@ -178,7 +76,7 @@ export const InnerStoryEditRoute: React.FC = () => { };; // This is a separate component so that the inner one can use -// `useEditorsContext()` and `useUndoableStoriesContext()` inside it. +// `useDialogsContext()` and `useUndoableStoriesContext()` inside it. export const StoryEditRoute: React.FC = () => ( diff --git a/src/routes/story-edit/toolbar/passage/__tests__/go-to-passage-button.test.tsx b/src/routes/story-edit/toolbar/passage/__tests__/go-to-passage-button.test.tsx new file mode 100644 index 000000000..d67a5568d --- /dev/null +++ b/src/routes/story-edit/toolbar/passage/__tests__/go-to-passage-button.test.tsx @@ -0,0 +1,32 @@ +import {fireEvent, render, screen} from '@testing-library/react'; +import {axe} from 'jest-axe'; +import * as React from 'react'; +import { + GoToPassageButton, + GoToPassageButtonProps +} from '../go-to-passage-button'; + +describe('GoToPassageButton', () => { + function renderComponent(props?: Partial) { + return render( + + ); + } + + it('calls the onOpenFuzzyFinder prop when clicked', () => { + const onOpenFuzzyFinder = jest.fn(); + + renderComponent({onOpenFuzzyFinder}); + expect(onOpenFuzzyFinder).not.toBeCalled(); + fireEvent.click( + screen.getByRole('button', {name: 'routes.storyEdit.toolbar.goTo'}) + ); + expect(onOpenFuzzyFinder).toBeCalledTimes(1); + }); + + it('is accessible', async () => { + const {container} = renderComponent(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/src/routes/story-edit/toolbar/passage/__tests__/passage-actions.test.tsx b/src/routes/story-edit/toolbar/passage/__tests__/passage-actions.test.tsx index 2219fef0c..1104767ff 100644 --- a/src/routes/story-edit/toolbar/passage/__tests__/passage-actions.test.tsx +++ b/src/routes/story-edit/toolbar/passage/__tests__/passage-actions.test.tsx @@ -18,6 +18,7 @@ const TestPassageActions: React.FC> = props => { return ( ({left: 0, top: 0})} + onOpenFuzzyFinder={jest.fn()} story={stories[0]} {...props} /> @@ -82,6 +83,13 @@ describe('', () => { ).toBeInTheDocument(); }); + it('displays a go to passage button', async () => { + await renderComponent(); + expect( + screen.getByText('routes.storyEdit.toolbar.goTo') + ).toBeInTheDocument(); + }); + it('displays a select all passages button', async () => { await renderComponent(); expect(screen.getByText('common.selectAll')).toBeInTheDocument(); diff --git a/src/routes/story-edit/toolbar/passage/go-to-passage-button.tsx b/src/routes/story-edit/toolbar/passage/go-to-passage-button.tsx new file mode 100644 index 000000000..f454ba372 --- /dev/null +++ b/src/routes/story-edit/toolbar/passage/go-to-passage-button.tsx @@ -0,0 +1,21 @@ +import {IconFocus2} from '@tabler/icons'; +import * as React from 'react'; +import {useTranslation} from 'react-i18next'; +import {IconButton} from '../../../../components/control/icon-button'; + +export interface GoToPassageButtonProps { + onOpenFuzzyFinder: () => void; +} + +export const GoToPassageButton: React.FC = props => { + const {onOpenFuzzyFinder} = props; + const {t} = useTranslation(); + + return ( + } + label={t('routes.storyEdit.toolbar.goTo')} + onClick={onOpenFuzzyFinder} + /> + ); +}; diff --git a/src/routes/story-edit/toolbar/passage/passage-actions.tsx b/src/routes/story-edit/toolbar/passage/passage-actions.tsx index 8e27ef3db..1c0b58dba 100644 --- a/src/routes/story-edit/toolbar/passage/passage-actions.tsx +++ b/src/routes/story-edit/toolbar/passage/passage-actions.tsx @@ -11,19 +11,19 @@ import {Point} from '../../../../util/geometry'; import {CreatePassageButton} from './create-passage-button'; import {DeletePassagesButton} from './delete-passages-button'; import {EditPassagesButton} from './edit-passages-buttons'; +import {GoToPassageButton} from './go-to-passage-button'; import {SelectAllPassagesButton} from './select-all-passages-button'; import {StartAtPassageButton} from './start-at-passage-button'; import {TestPassageButton} from './test-passage-button'; export interface PassageActionsProps { getCenter: () => Point; + onOpenFuzzyFinder: () => void; story: Story; } -export const PassageActions: React.FC = ({ - getCenter, - story -}) => { +export const PassageActions: React.FC = props => { + const {getCenter, onOpenFuzzyFinder, story} = props; const {dispatch} = useStoriesContext(); const selectedPassages = React.useMemo( () => story.passages.filter(passage => passage.selected), @@ -59,6 +59,7 @@ export const PassageActions: React.FC = ({ + ); diff --git a/src/routes/story-edit/toolbar/story-edit-toolbar.tsx b/src/routes/story-edit/toolbar/story-edit-toolbar.tsx index e60e72b66..4a77fdcf0 100644 --- a/src/routes/story-edit/toolbar/story-edit-toolbar.tsx +++ b/src/routes/story-edit/toolbar/story-edit-toolbar.tsx @@ -10,11 +10,12 @@ import {UndoRedoButtons} from './undo-redo-buttons'; export interface StoryEditToolbarProps { getCenter: () => Point; + onOpenFuzzyFinder: () => void; story: Story; } export const StoryEditToolbar: React.FC = props => { - const {getCenter, story} = props; + const {getCenter, onOpenFuzzyFinder, story} = props; const {t} = useTranslation(); return ( @@ -22,7 +23,11 @@ export const StoryEditToolbar: React.FC = props => { pinnedControls={} tabs={{ [t('common.passage')]: ( - + ), [t('common.story')]: , [t('common.build')]: , diff --git a/src/routes/story-edit/use-initial-passage-creation.ts b/src/routes/story-edit/use-initial-passage-creation.ts new file mode 100644 index 000000000..36d7a8947 --- /dev/null +++ b/src/routes/story-edit/use-initial-passage-creation.ts @@ -0,0 +1,27 @@ +import * as React from 'react'; +import {createUntitledPassage, Story} from '../../store/stories'; +import {useUndoableStoriesContext} from '../../store/undoable-stories'; +import {Point} from '../../util/geometry'; + +export function useInitialPassageCreation( + story: Story, + getCenter: () => Point +) { + const {dispatch} = useUndoableStoriesContext(); + const [inited, setInited] = React.useState(false); + + // If we have just mounted and the story has no passages, create one for the + // user (and skip undo history, since it was an automatic action). + + React.useEffect(() => { + if (!inited) { + setInited(true); + + if (story.passages.length === 0) { + const center = getCenter(); + + dispatch(createUntitledPassage(story, center.left, center.top)); + } + } + }, [dispatch, getCenter, inited, story]); +} diff --git a/src/routes/story-edit/use-passage-change-handlers.ts b/src/routes/story-edit/use-passage-change-handlers.ts new file mode 100644 index 000000000..425b933c3 --- /dev/null +++ b/src/routes/story-edit/use-passage-change-handlers.ts @@ -0,0 +1,102 @@ +import * as React from 'react'; +import {PassageEditDialog, useDialogsContext} from '../../dialogs'; +import { + deselectPassage, + movePassages, + Passage, + selectPassage, + selectPassagesInRect, + Story +} from '../../store/stories'; +import {useUndoableStoriesContext} from '../../store/undoable-stories'; +import {Point, Rect} from '../../util/geometry'; + +export function usePassageChangeHandlers(story: Story) { + const selectedPassages = React.useMemo( + () => story.passages.filter(passage => passage.selected), + [story.passages] + ); + const {dispatch: undoableStoriesDispatch} = useUndoableStoriesContext(); + const {dispatch: dialogsDispatch} = useDialogsContext(); + + const handleDeselectPassage = React.useCallback( + (passage: Passage) => + undoableStoriesDispatch(deselectPassage(story, passage)), + [story, undoableStoriesDispatch] + ); + + const handleDragPassages = React.useCallback( + (change: Point) => { + // Ignore tiny drags--they're probably caused by the user moving their + // mouse slightly during double-clicking. + + if (Math.abs(change.left) < 1 && Math.abs(change.top) < 1) { + return; + } + + undoableStoriesDispatch( + movePassages( + story, + story.passages.reduce( + (result, current) => + current.selected ? [...result, current.id] : result, + [] + ), + change.left / story.zoom, + change.top / story.zoom + ), + selectedPassages.length > 1 + ? 'undoChange.movePassages' + : 'undoChange.movePassages' + ); + }, + [selectedPassages.length, story, undoableStoriesDispatch] + ); + + const handleEditPassage = React.useCallback( + (passage: Passage) => + dialogsDispatch({ + type: 'addDialog', + component: PassageEditDialog, + props: {passageId: passage.id, storyId: story.id} + }), + [dialogsDispatch, story.id] + ); + + const handleSelectPassage = React.useCallback( + (passage: Passage, exclusive: boolean) => + undoableStoriesDispatch(selectPassage(story, passage, exclusive)), + [story, undoableStoriesDispatch] + ); + + const handleSelectRect = React.useCallback( + (rect: Rect, additive: boolean) => { + // The rect we receive is in screen coordinates--we need to convert to + // logical ones. + const logicalRect: Rect = { + height: rect.height / story.zoom, + left: rect.left / story.zoom, + top: rect.top / story.zoom, + width: rect.width / story.zoom + }; + + // This should not be undoable. + undoableStoriesDispatch( + selectPassagesInRect( + story, + logicalRect, + additive ? selectedPassages.map(passage => passage.id) : [] + ) + ); + }, + [selectedPassages, story, undoableStoriesDispatch] + ); + + return { + handleDeselectPassage, + handleDragPassages, + handleEditPassage, + handleSelectPassage, + handleSelectRect + }; +} diff --git a/src/routes/story-edit/use-view-center.ts b/src/routes/story-edit/use-view-center.ts new file mode 100644 index 000000000..dfd2a27fc --- /dev/null +++ b/src/routes/story-edit/use-view-center.ts @@ -0,0 +1,48 @@ +import * as React from 'react'; +import {useDialogsContext} from '../../dialogs'; +import {usePrefsContext} from '../../store/prefs'; +import {Story} from '../../store/stories'; +import {Point} from '../../util/geometry'; + +export function useViewCenter(story: Story, domElement: HTMLElement | null) { + const {dialogs} = useDialogsContext(); + const {prefs} = usePrefsContext(); + + const getCenter = React.useCallback(() => { + if (!domElement) { + throw new Error( + 'Asked for the center of an element, but it does not exist in the DOM yet' + ); + } + + return { + left: (domElement.scrollLeft + domElement.clientWidth / 2) / story.zoom, + top: (domElement.scrollTop + domElement.clientHeight / 2) / story.zoom + }; + }, [domElement, story.zoom]); + + const setCenter = React.useCallback( + ({left, top}: Point) => { + if (!domElement) { + throw new Error( + 'Asked to set the center of an element, but it does not exist in the DOM yet' + ); + } + + const {height, width} = domElement.getBoundingClientRect(); + const scroll = { + left: (left - width / story.zoom / 2) * story.zoom, + top: (top - height / story.zoom / 2) * story.zoom + }; + + if (dialogs.length > 0) { + scroll.left += prefs.dialogWidth / 2; + } + + domElement.scrollTo(scroll); + }, + [dialogs.length, domElement, prefs.dialogWidth, story.zoom] + ); + + return {getCenter, setCenter}; +} diff --git a/src/store/stories/__tests__/getters.test.ts b/src/store/stories/__tests__/getters.test.ts index 25b0be409..ef261cc22 100644 --- a/src/store/stories/__tests__/getters.test.ts +++ b/src/store/stories/__tests__/getters.test.ts @@ -5,7 +5,8 @@ import { storyWithId, storyWithName, storyTags, - passagesMatchingSearch + passagesMatchingSearch, + passagesMatchingFuzzySearch } from '../getters'; import {Passage, Story} from '../stories.types'; import {fakePassage, fakeStory} from '../../../test-util'; @@ -183,6 +184,44 @@ describe('passageWithName()', () => { ).toThrow()); }); +describe('passagesMatchingFuzzySearch', () => { + let passages: Passage[]; + + beforeEach( + () => + (passages = [ + fakePassage({name: 'A name', text: 'A text'}), + fakePassage({name: 'B name', text: 'B text'}), + fakePassage({name: 'C name', text: 'C text'}), + fakePassage({name: 'D name', text: 'D text'}), + fakePassage({name: 'bad', text: 'bad'}), + fakePassage({name: 'bad', text: 'bad'}), + fakePassage({name: 'bad', text: 'bad'}), + fakePassage({name: 'E name', text: 'E text'}) + ]) + ); + + it('returns the top five results matching a search by default', () => { + expect(passagesMatchingFuzzySearch(passages, 'txet')).toEqual([ + passages[0], + passages[1], + passages[2], + passages[3], + passages[7] + ]); + }); + + it('limits search results to the count specified', () => { + expect(passagesMatchingFuzzySearch(passages, 'txet', 1)).toEqual([ + passages[0] + ]); + }); + + it('returns an empty array if no passages match', () => { + expect(passagesMatchingFuzzySearch(passages, 'nonexistent', 1)).toEqual([]); + }); +}); + describe('passagesMatchingSearch()', () => { let passages: Passage[]; diff --git a/src/store/stories/getters.ts b/src/store/stories/getters.ts index f7f601cc9..54ad4430d 100644 --- a/src/store/stories/getters.ts +++ b/src/store/stories/getters.ts @@ -1,3 +1,4 @@ +import Fuse from 'fuse.js'; import uniq from 'lodash/uniq'; import {Passage, StorySearchFlags, Story} from './stories.types'; import {createRegExp} from '../../util/regexp'; @@ -91,6 +92,29 @@ export function passageConnections( return result; } +/** + * Returns a set of passages matching a fuzzy search crtieria. + */ +export function passagesMatchingFuzzySearch( + passages: Passage[], + search: string, + count = 5 +) { + if (search.trim() === '') { + return []; + } + + const fuse = new Fuse(passages, { + ignoreLocation: true, + keys: [ + {name: 'name', weight: 0.6}, + {name: 'text', weight: 0.4} + ] + }); + + return fuse.search(search, {limit: count}).map(({item}) => item); +} + /** * Returns all passages matching a search criteria. Use * `highlightPassageMatches()` to highlight exactly what matched. From dc7e616104ee0a11487563aa5a4b6f9ba54fbb22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=AB=E4=B9=90=E7=9A=84=E8=80=81=E9=BC=A0=E5=AE=9D?= =?UTF-8?q?=E5=AE=9D?= Date: Thu, 24 Nov 2022 05:40:51 +0800 Subject: [PATCH 24/43] =?UTF-8?q?=E9=80=90=E8=A1=8C=E4=B8=8E=E8=8B=B1?= =?UTF-8?q?=E6=96=87=E4=BF=9D=E6=8C=81=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/locales/zh-CN.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/public/locales/zh-CN.json b/public/locales/zh-CN.json index 8acf0aae6..be39926f6 100644 --- a/public/locales/zh-CN.json +++ b/public/locales/zh-CN.json @@ -9,10 +9,14 @@ "purple": "紫色" }, "common": { + "add": "添加", + "appName": "Twine", "back": "返回", "build": "构建", + "cancel": "取消", "close": "关闭", "color": "颜色", + "create": "创建", "custom": "自定义", "details": "细节", "editCount": "编辑({{count}})", From 045d50598ac533eeaaed446dd0365eb30e913ab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=AB=E4=B9=90=E7=9A=84=E8=80=81=E9=BC=A0=E5=AE=9D?= =?UTF-8?q?=E5=AE=9D?= Date: Thu, 24 Nov 2022 05:45:28 +0800 Subject: [PATCH 25/43] =?UTF-8?q?=E6=8C=89=E7=85=A7=E8=8B=B1=E6=96=87?= =?UTF-8?q?=E7=89=88=E7=9A=84=E9=A1=BA=E5=BA=8F=E6=8E=92=E5=BA=8F=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E8=A1=A5=E5=85=A8=E7=BC=BA=E5=A4=B1=E7=9A=84=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/locales/zh-CN.json | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/public/locales/zh-CN.json b/public/locales/zh-CN.json index be39926f6..2e92c940a 100644 --- a/public/locales/zh-CN.json +++ b/public/locales/zh-CN.json @@ -18,40 +18,39 @@ "color": "颜色", "create": "创建", "custom": "自定义", + "delete": "删除", + "deleteCount": "删除({{count}})", "details": "细节", + "duplicate": "复制", + "edit": "编辑", "editCount": "编辑({{count}})", - "deleteCount": "删除({{count}})", "help": "帮助", "import": "导入", + "maximize": "最大化", "more": "更多", "new": "新建", "next": "下一个", + "ok": "好的", "passage": "片段", + "play": "运行", "preferences": "偏好设置", "publishToFile": "发布到文件", "redo": "恢复", "redoChange": "恢复 {{change}}", - "renamePrompt": "您想要将“{{name}}”重命名为什么?", - "selectAll": "全选", - "story": "故事", - "twine": "Twine", - "undoChange": "撤销 {{change}}", - "view": "查看", - "add": "添加", - "appName": "Twine", - "cancel": "取消", - "delete": "删除", - "duplicate": "复制", - "edit": "编辑", - "ok": "好的", - "play": "运行", "rename": "重命名", + "renamePrompt": "您想要将“{{name}}”重命名为什么?", "remove": "移除", + "selectAll": "全选", "skip": "跳过", + "story": "故事", "storyFormat": "故事格式", "tag": "标签", "test": "测试", - "undo": "撤销" + "twine": "Twine", + "undo": "撤销", + "undoChange": "撤销 {{change}}", + "unmaximize": "还原大小", + "view": "查看" }, "components": { "addTagButton": { @@ -392,4 +391,4 @@ "publishToFile": "发布到文件" } } -} +} \ No newline at end of file From 8569105700ecb30903f9a8ef9920581356365125 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=AB=E4=B9=90=E7=9A=84=E8=80=81=E9=BC=A0=E5=AE=9D?= =?UTF-8?q?=E5=AE=9D?= Date: Thu, 24 Nov 2022 05:54:29 +0800 Subject: [PATCH 26/43] =?UTF-8?q?"components"=E9=83=A8=E5=88=86=E6=8E=92?= =?UTF-8?q?=E5=BA=8F=E5=92=8C=E8=A1=A5=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/locales/zh-CN.json | 65 ++++++++++++++++++++++----------------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/public/locales/zh-CN.json b/public/locales/zh-CN.json index 2e92c940a..4b8bc7786 100644 --- a/public/locales/zh-CN.json +++ b/public/locales/zh-CN.json @@ -61,9 +61,12 @@ "tagColorLabel": "标签颜色", "tagNameLabel": "标签名称" }, + "dialogCard": { + "contentsCrashed": "这一对话似乎遇到了错误。尝试关闭它并重新打开。" + }, "fontSelect": { - "customFamilyDetail": "请只输入字体名称。", "customScaleDetail": "请只输入百分比数值。", + "customFamilyDetail": "请只输入字体名称。", "familyEmpty": "请输入字体名称。", "font": "字体", "fonts": { @@ -76,62 +79,66 @@ "percentageIsntNumber": "请输入数字。", "percentageNotPositive": "请输入大于 0 的数字。" }, + "indentButtons": { + "indent": "缩进", + "unindent": "无缩进" + }, + "localStorageQuota": { + "measureAgain": "再次检测可用空间", + "percentAvailable": "{percent}% 空间可用" + }, "passageCard": { "placeholderClick": "双击该段落进行编辑。", "placeholderTouch": "点击这个片段,然后点击铅笔图标进行编辑。" }, - "renamePassageButton": { - "nameAlreadyUsed": "此名称已被故事中的另一个片段使用。", - "emptyName": "请输入一个名字。" + "passageFuzzyFinder": { + "noResults": "No matches.", + "prompt": "Search by passage name or text" }, - "storyCard": { - "passageCount": "{{count}}个片段", - "lastUpdated": "最后一次编辑位于 {{date}}", - "passageCount_plural": "{{count}} 个片段" - }, - "indentButtons": { - "indent": "缩进", - "unindent": "无缩进" + "renamePassageButton": { + "emptyName": "请输入一个名字。", + "nameAlreadyUsed": "此名称已被故事中的另一个片段使用。" }, "renameStoryButton": { "emptyName": "请输入一个名字。", "nameAlreadyUsed": "此名称已被另一个故事使用。" }, "safariWarningCard": { - "message": "如果您连续七天没有打开该网站,您正使用的浏览器就将删除所有故事。", "archiveAndUseAnotherBrowser": "请保存故事并改用其他平台。", - "howToAddToHomeScreen": "我该如何把它添加到我的主界面?", "addToHomeScreen": "你可以通过把该网站添加到主界面来避免限制。", - "learnMore": "了解更多" + "howToAddToHomeScreen": "我该如何把它添加到我的主界面?", + "learnMore": "了解更多", + "message": "如果您连续七天没有打开该网站,您正使用的浏览器就将删除所有故事。" }, - "storyFormatSelect": { - "loadingCount_plural": "载入 {{loadingCount}} 个故事格式中……", - "loadingCount": "载入 1 个故事格式中……" + "storageQuota": { + "freeSpace": "{{percent}}% 空间可用" + }, + "storyCard": { + "lastUpdated": "最后一次编辑位于 {{date}}", + "passageCount": "{{count}}个片段", + "passageCount_plural": "{{count}} 个片段" }, "storyFormatCard": { "author": "来自 {{author}}", + "builtIn": "内置", "defaultFormat": "设为默认", + "editorExtensionsDisabled": "编辑器扩展被禁用", "license": "许可证:{{license}}", "loadingFormat": "载入故事格式中……", "loadError": "故事格式加载失败({{errorMessage}})。", "name": "{{name}} {{version}}", - "useFormat": "设为默认故事格式", "proofing": "校对", "proofingFormat": "用于校对", - "useProofingFormat": "设为校对格式", - "editorExtensionsDisabled": "编辑器扩展被禁用", "useEditorExtensions": "使用编辑器扩展", - "builtIn": "内置" + "useFormat": "设为默认故事格式", + "useProofingFormat": "设为校对格式" + }, + "storyFormatSelect": { + "loadingCount": "载入 1 个故事格式中……", + "loadingCount_plural": "载入 {{loadingCount}} 个故事格式中……" }, "tagEditor": { "alreadyExists": "已存在同名标签。" - }, - "localStorageQuota": { - "measureAgain": "再次检测可用空间", - "percentAvailable": "{percent}% 空间可用" - }, - "storageQuota": { - "freeSpace": "{{percent}}% 空间可用" } }, "dialogs": { From 6b159d3e7a2463b0b46ad58f37dca0d63b29080c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=AB=E4=B9=90=E7=9A=84=E8=80=81=E9=BC=A0=E5=AE=9D?= =?UTF-8?q?=E5=AE=9D?= Date: Thu, 24 Nov 2022 06:04:40 +0800 Subject: [PATCH 27/43] =?UTF-8?q?"dialogs"=E9=83=A8=E5=88=86=EF=BC=8C?= =?UTF-8?q?=E6=8E=92=E5=BA=8F=E5=92=8C=E8=A1=A5=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/locales/zh-CN.json | 79 ++++++++++++++++++++++----------------- 1 file changed, 44 insertions(+), 35 deletions(-) diff --git a/public/locales/zh-CN.json b/public/locales/zh-CN.json index 4b8bc7786..061fce42a 100644 --- a/public/locales/zh-CN.json +++ b/public/locales/zh-CN.json @@ -144,64 +144,50 @@ "dialogs": { "aboutTwine": { "donateToTwine": "通过捐赠帮助 Twine 开发", - "license": "本应用程序是在 GPL v3 license 许可证下发布的,以其创建的任何作品都可能以任何条款发布,包括商业应用程序。", "codeHeader": "代码", "codeRepo": "访问源代码库", + "license": "本应用程序是在 GPL v3 license 许可证下发布的,以其创建的任何作品都可能以任何条款发布,包括商业应用程序。", "localizationHeader": "本地化", "title": "关于 Twine {{version}}", "twineDescription": "Twine 是一个用于创作互动的非线性故事的开源工具。" }, "appDonation": { - "noThanks": "不,谢谢", - "supportMessage": "如果你热爱 Twine,请考虑通过捐赠帮助它成长。Twine 是一个永远免费使用的开源项目,在你的帮助下,Twine 将继续蓬勃发展。", "donate": "捐赠给 Twine 开发", "onlyOnce": "(此消息只会显示给您一次。如果您希望将来捐赠给 Twine 开发,您可以在在“关于 Twine”对话框中可以找到相关链接。)", + "supportMessage": "如果你热爱 Twine,请考虑通过捐赠帮助它成长。Twine 是一个永远免费使用的开源项目,在你的帮助下,Twine 将继续蓬勃发展。", + "noThanks": "不,谢谢", "title": "支持 Twine 开发" }, "appPrefs": { - "language": "语言", "codeEditorFont": "代码编辑器字体", "codeEditorFontScale": "代码编辑器字体大小", + "dialogWidth": "对话框宽度", + "dialogWidths": { + "default": "默认", + "wider": "较宽", + "widest": "最宽" + }, "editorCursorBlinks": "代码编辑器中的光标闪烁", "fontExplanation": "此处的字体改变只影响 Twine 编辑器,游玩故事的字体不会发生改变。", + "language": "语言", "passageEditorFont": "片段编辑器字体", "passageEditorFontScale": "片段编辑器字体大小", - "themeLight": "亮", - "themeDark": "暗", + "themeLight": "亮色", + "themeDark": "暗色", "themeSystem": "跟随系统", "theme": "主题", "title": "偏好设置" }, "passageEdit": { + "editorCrashed": "编辑器遇到了一些错误。尝试关闭它并重新编辑这个片段。", + "passageTextEditorLabel": "片段文本", + "passageTextPlaceholder": "在这里输入您的片段的文本。要链接到其他片段需要在它名字前后加两个方括号,[[就像这样]]。", "setAsStart": "从这里开始故事", + "size": "尺寸", "sizeLarge": "大型", "sizeSmall": "小型", "sizeTall": "竖条", - "sizeWide": "横条", - "passageTextEditorLabel": "片段文本", - "size": "形状" - }, - "storyJavaScript": { - "explanation": "当您的故事在Web浏览器中打开时,此处输入的任何JavaScript都将立即运行。", - "editorLabel": "故事 Javascript 脚本", - "title": "故事 Javascript 脚本" - }, - "storySearch": { - "title": "查找替换", - "replaceWith": "替换为", - "includePassageNames": "包含名称", - "matchCase": "匹配大小写", - "matchCount": "{{count}} 个匹配片段", - "matchCount_plural": "{{count}} 个匹配片段", - "noMatches": "没有匹配片段", - "replaceAll": "替换所有片段中的项", - "useRegexes": "使用正则表达式", - "find": "查找" - }, - "storyStylesheet": { - "explanation": "这里输入的任何CSS都会覆盖故事的默认外观。", - "editorLabel": "故事样式表", - "title": "故事样式表" + "sizeWide": "横条" }, "passageTags": { "noTags": "尚未向故事中的片段添加任何标签。", @@ -209,12 +195,12 @@ }, "storyImport": { "deselectAll": "全不选", - "storiesPrompt": "选择要导入的故事:", "filePrompt": "要向 Twine 导入故事,请先在下方上传一个档案或已发布的故事文件。", - "importSelected": "导入选中文件", "importDifferentFile": "导入其他文件", + "importSelected": "导入选中文件", "importThisStory": "导入该故事", "noStoriesInFile": "您上传的文件中似乎没有 Twine 故事。请选择其他文件。", + "storiesPrompt": "选择要导入的故事:", "title": "导入故事", "willReplaceExisting": "将会替换故事库中的同名故事。" }, @@ -222,9 +208,9 @@ "storyFormatExplanation": "故事格式是什么?", "snapToGrid": "对齐到网格", "stats": { - "title": "故事统计", - "brokenLinks": "断开连接", + "brokenLinks": "断开的连接", "characters": "字符", + "title": "故事统计", "ifid": "该故事的 IFID 是 {{ifid}}。", "ifidExplanation": "什么是 IFID?", "lastUpdate": "故事的最后一次变更是在 {{date}}。", @@ -233,6 +219,29 @@ "words": "字" } }, + "storyJavaScript": { + "editorLabel": "故事 Javascript 脚本", + "title": "故事 Javascript 脚本", + "explanation": "当您的故事在Web浏览器中打开时,此处输入的任何JavaScript都将立即运行。" + }, + "storySearch": { + "title": "查找替换", + "find": "查找", + "includePassageNames": "包含名称", + "matchCase": "匹配大小写", + "matchCount": "{{count}} 个匹配片段", + "matchCount_plural": "{{count}} 个匹配片段", + "noMatches": "没有匹配片段", + "replaceAll": "替换所有片段中的项", + "replaceWith": "替换为", + "useRegexes": "使用正则表达式" + }, + "storyStylesheet": { + "editorLabel": "故事样式表", + "title": "故事样式表", + "explanation": "这里输入的任何CSS都会覆盖故事的默认外观。" + + }, "storyTags": { "noTags": "尚未向您的故事中添加任何标签。", "title": "故事标签" From d46cbdb4802eb2c98b55f57adb229611a937dbd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=AB=E4=B9=90=E7=9A=84=E8=80=81=E9=BC=A0=E5=AE=9D?= =?UTF-8?q?=E5=AE=9D?= Date: Thu, 24 Nov 2022 06:10:36 +0800 Subject: [PATCH 28/43] =?UTF-8?q?"electron"=E9=83=A8=E5=88=86=E6=8E=92?= =?UTF-8?q?=E5=BA=8F=E5=92=8C=E8=A1=A5=E5=85=85=EF=BC=8C=E5=B9=B6=E6=8E=92?= =?UTF-8?q?=E5=BA=8F=E6=9B=B4=E9=AB=98=E5=B1=82=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/locales/zh-CN.json | 84 +++++++++++++++++++++------------------ 1 file changed, 45 insertions(+), 39 deletions(-) diff --git a/public/locales/zh-CN.json b/public/locales/zh-CN.json index 061fce42a..594a3beed 100644 --- a/public/locales/zh-CN.json +++ b/public/locales/zh-CN.json @@ -240,49 +240,43 @@ "editorLabel": "故事样式表", "title": "故事样式表", "explanation": "这里输入的任何CSS都会覆盖故事的默认外观。" - }, "storyTags": { "noTags": "尚未向您的故事中添加任何标签。", "title": "故事标签" } }, - "undoChange": { - "replaceAllText": "替换所有", - "changeTagColor": "更改标签颜色", - "newPassage": "新建片段", - "deletePassage": "删除片段", - "deletePassages": "删除片段", - "movePassage": "移动片段", - "movePassages": "移动片段", - "imortTag": "移除标签", - "renamePassage": "重命名片段", - "renameTag": "重命名标签" - }, "electron": { - "menuBar": { - "edit": "编辑", - "twineHelp": "Twine 帮助", - "view": "查看", - "showDevTools": "打开 Debug 控制台", - "showStoryLibrary": "打开故事库", - "troubleshooting": "问题分析", - "speech": "台词" - }, - "storiesDirectoryName": "故事", + "backupsDirectoryName": "备份", "errors": { + "jsonSave": "保存设定文件时发生错误。", "storyFileChangedExternally": { - "detail": "保存更改将会覆盖该文件。如果您想使用该文件而不是 Twine 内的版本, Twine 将会重启,您的作品中的内容会改为使用该文件。", "message": "故事中的文件“{{fileName}}”在 Twine 外被修改。", + "detail": "保存更改将会覆盖该文件。如果您想使用该文件而不是 Twine 内的版本, Twine 将会重启,您的作品中的内容会改为使用该文件。", "overwriteChoice": "保存 Twine 的更改", "relaunchChoice": "使用该文件并重启" }, - "jsonSave": "保存设定文件时发生错误。", "storyDelete": "删除故事时发生错误。", "storyRename": "重命名故事时发生错误。", "storySave": "保存故事时发生错误。" }, - "backupsDirectoryName": "备份" + "menuBar": { + "checkForUpdates": "检查更新……", + "edit": "编辑", + "showDevTools": "打开 Debug 控制台", + "showStoryLibrary": "打开故事库", + "speech": "台词", + "troubleshooting": "问题分析", + "twineHelp": "Twine 帮助", + "view": "查看", + }, + "storiesDirectoryName": "故事", + "updateCheck": { + "download": "下载", + "error": "在检查Twine更新版本时遇到了错误。", + "updateAvailable": "较新版本的Twine现已可用。", + "upToDate": "已经是Twine可用的最新版本。" + } }, "routes": { "storyEdit": { @@ -374,6 +368,20 @@ "done": "

    感谢您的阅读,希望使用 Twine 愉快!

    " } }, + "routeActions": { + "app": { + "aboutApp": "关于 Twine", + "preferences": "偏好", + "reportBug": "反馈 Bug", + "storyFormats": "故事格式" + }, + "build": { + "proof": "校对", + "test": "测试", + "play": "运行", + "publishToFile": "发布到文件" + } + }, "store": { "passageDefaults": { "name": "未命名片段" @@ -393,18 +401,16 @@ }, "archiveFilename": "{{timestamp}} Twine 档案.html" }, - "routeActions": { - "app": { - "aboutApp": "关于 Twine", - "preferences": "偏好", - "reportBug": "反馈 Bug", - "storyFormats": "故事格式" - }, - "build": { - "proof": "校对", - "test": "测试", - "play": "运行", - "publishToFile": "发布到文件" - } + "undoChange": { + "replaceAllText": "替换所有", + "changeTagColor": "更改标签颜色", + "newPassage": "新建片段", + "deletePassage": "删除片段", + "deletePassages": "删除片段", + "movePassage": "移动片段", + "movePassages": "移动片段", + "imortTag": "移除标签", + "renamePassage": "重命名片段", + "renameTag": "重命名标签" } } \ No newline at end of file From 788b8b9fd4127f5091f7ba85568bd78cf4956f73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=AB=E4=B9=90=E7=9A=84=E8=80=81=E9=BC=A0=E5=AE=9D?= =?UTF-8?q?=E5=AE=9D?= Date: Thu, 24 Nov 2022 06:18:35 +0800 Subject: [PATCH 29/43] =?UTF-8?q?=E5=AE=8C=E6=88=90"routes"=E7=9A=84?= =?UTF-8?q?=E6=A0=A1=E5=AF=B9=E6=8E=92=E5=BA=8F=E5=92=8C=E6=B7=BB=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/locales/zh-CN.json | 84 +++++++++++++++++++++------------------ 1 file changed, 45 insertions(+), 39 deletions(-) diff --git a/public/locales/zh-CN.json b/public/locales/zh-CN.json index 594a3beed..eccd23863 100644 --- a/public/locales/zh-CN.json +++ b/public/locales/zh-CN.json @@ -280,23 +280,24 @@ }, "routes": { "storyEdit": { + "toolbar": { + "findAndReplace": "查找替换", + "goTo": "跳转到", + "javaScript": "JavaScript", + "passageTags": "片段标签", + "snapToGrid": "对齐到网格", + "startStoryHere": "从这里开始故事", + "stylesheet": "样式表", + "testFromHere": "从这里开始测试" + }, "topBar": { "editJavaScript": "编辑故事 Javascript 脚本", "editStylesheet": "编辑故事样式表", "findAndReplace": "查找替换", + "passageTags": "编辑片段标签", "proofStory": "查看校对版本", "publishToFile": "发布到文件", - "selectAllPassages": "选择所有片段", - "passageTags": "编辑片段标签" - }, - "toolbar": { - "javaScript": "JavaScript", - "stylesheet": "样式表", - "snapToGrid": "对齐到网格", - "passageTags": "片段标签", - "findAndReplace": "查找替换", - "startStoryHere": "从这里开始故事", - "testFromHere": "从这里开始测试" + "selectAllPassages": "选择所有片段" }, "zoomButtons": { "storyStructure": "仅显示故事结构", @@ -305,10 +306,14 @@ } }, "storyFormatList": { - "storyFormatExplanation": "故事格式用于控制游戏过程中的故事外观和行为。", + "noneVisible": "没有符合您筛选标准的故事格式。", + "show": "显示……", + "title": { + "all": "全部故事格式", + "current": "当前故事格式", + "user": "用户添加的故事格式" + }, "toolbar": { - "useAsProofingFormat": "用于校对故事", - "useAsDefaultFormat": "设为默认格式", "addStoryFormatButton": { "addPreview": "将添加 {{storyFormatName}} {{storyFormatVersion}}。", "alreadyAdded": "已添加 {{storyFormatName}} {{storyFormatVersion}}。", @@ -317,24 +322,29 @@ "prompt": "要添加故事格式,请在下方输入地址。" }, "disableFormatExtensions": "禁用编辑器扩展", - "enableFormatExtensions": "启用编辑器扩展" + "enableFormatExtensions": "启用编辑器扩展", + "useAsDefaultFormat": "设为默认格式", + "useAsProofingFormat": "用于校对故事" }, - "show": "显示……", - "noneVisible": "没有符合您筛选标准的故事格式。", - "title": { - "all": "全部故事格式", - "current": "当前故事格式", - "user": "用户添加的故事格式" - } + "storyFormatExplanation": "故事格式用于控制游戏过程中的故事外观和行为。" }, "storyList": { + "library": "库", "noStories": "在 Twine 中没有保存的故事。您可以创建一个新故事或从文件导入现有的故事。", + "taggedTitleCount": "1 个有标签的故事", + "taggedTitleCount_0": "不存在有标签的故事", + "taggedTitleCount_plural": "{{count}} 个有标签的故事", + "titleCount": "1 个故事", + "titleCount_0": "无故事", + "titleCount_plural": "{{count}} 个故事", "titleGeneric": "故事", "toolbar": { "archive": "档案", - "sort": "排序方式", - "sortByDate": "日期", - "sortByName": "名称", + "createStoryButton": { + "prompt": "您故事的名称是什么?您可以随后更改它。", + "emptyName": "请输入一个名称。", + "nameConflict": "其他故事已使用这一名称。" + }, "deleteStoryButton": { "warning": { "electron": "您确定要删除“{{storyName}}”吗?它将会被移入回收站。", @@ -343,29 +353,25 @@ }, "showAllStories": "显示所有故事", "showTags": "显示标签", + "sort": "排序方式", + "sortByDate": "日期", + "sortByName": "名称", "storyTags": "故事标签" - }, - "titleCount_plural": "{{count}} 个故事", - "library": "库", - "taggedTitleCount_0": "不存在有标签的故事", - "taggedTitleCount": "1 个有标签的故事", - "taggedTitleCount_plural": "{{count}} 个有标签的故事", - "titleCount": "1 个故事", - "titleCount_0": "无故事" + } }, "welcome": { + "autosave": "

    您的 Documents 文件夹中现在有一个名为 Twine 的文件夹。 里面是一个故事文件夹,所有的作品都将被保存。 Twine 自动为你保存作品,所以你不必担心自己忘记保存。 您可以随时使用 Twine 上菜单的打开故事库项,来打开您的故事库文件夹。

    由于 Twine 始终保存您的作品,因此在 Twine 打开时,故事库中的文件将被锁定而无法编辑。

    如果您想打开从别人那里获得的故事,可以使用故事列表中的从文件导入链接来向故事库中导入文件。

    ", "autosaveTitle": "您的作品将自动保存。", + "browserStorage": "

    这意味着你不需要创建一个账号来使用 Twine 2,并且你创建的所有内容都不会被存储在其他地方的服务器上,它只保存在你的浏览器中。

    不过,要记住两件非常重要的事情。 由于您的作品仅保存在您的浏览器中,因此如果您清除了保存的数据,那么您将失去作品! 不好。 请记住经常使用存档按钮。 您还可以使用故事列表中每个故事的菜单将单个故事发布到文件。 档案和故事文件都可以重新导入到 Twine 中。

    其次,任何可以使用此浏览器的人都可以查看并更改您的作品。所以,如果你家里有一位熊孩子角色,最好为自己做一个文件备份。

    ", + "browserStorageTitle": "您的作品仅保存在浏览器中", + "done": "

    感谢您的阅读,希望使用 Twine 愉快!

    ", "doneTitle": "就是这样!", "gotoStoryList": "返回故事列表", + "greeting": "

    Twine 是一个开源的工具,用于展示互动的非线性故事。 在开始之前,你应该了解一些事情。

    ", "greetingTitle": "(。・∀・)ノ゙嗨!", "tellMeMore": "告诉我更多", - "helpTitle": "新来的?", "help": "

    如果你是第一次使用 Twine,那么欢迎你来使用 Twine!这个 Twine 指南书可以很好地教你如何使用 Twine。你之前从未用过 Twine 的话,这是个不错的开始。

    ", - "autosave": "

    您的 Documents 文件夹中现在有一个名为 Twine 的文件夹。 里面是一个故事文件夹,所有的作品都将被保存。 Twine 自动为你保存作品,所以你不必担心自己忘记保存。 您可以随时使用 Twine 上菜单的打开故事库项,来打开您的故事库文件夹。

    由于 Twine 始终保存您的作品,因此在 Twine 打开时,故事库中的文件将被锁定而无法编辑。

    如果您想打开从别人那里获得的故事,可以使用故事列表中的从文件导入链接来向故事库中导入文件。

    ", - "browserStorage": "

    这意味着你不需要创建一个账号来使用 Twine 2,并且你创建的所有内容都不会被存储在其他地方的服务器上,它只保存在你的浏览器中。

    不过,要记住两件非常重要的事情。 由于您的作品仅保存在您的浏览器中,因此如果您清除了保存的数据,那么您将失去作品! 不好。 请记住经常使用存档按钮。 您还可以使用故事列表中每个故事的菜单将单个故事发布到文件。 档案和故事文件都可以重新导入到 Twine 中。

    其次,任何可以使用此浏览器的人都可以查看并更改您的作品。所以,如果你家里有一位熊孩子角色,最好为自己做一个文件备份。

    ", - "greeting": "

    Twine 是一个开源的工具,用于展示互动的非线性故事。 在开始之前,你应该了解一些事情。

    ", - "browserStorageTitle": "您的作品仅保存在浏览器中", - "done": "

    感谢您的阅读,希望使用 Twine 愉快!

    " + "helpTitle": "新来的?" } }, "routeActions": { From 5f3dfd7bd50803ad2e95845f790c3f7262d0fe10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=AB=E4=B9=90=E7=9A=84=E8=80=81=E9=BC=A0=E5=AE=9D?= =?UTF-8?q?=E5=AE=9D?= Date: Thu, 24 Nov 2022 06:22:04 +0800 Subject: [PATCH 30/43] FINISH! --- public/locales/zh-CN.json | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/public/locales/zh-CN.json b/public/locales/zh-CN.json index eccd23863..c6adddf45 100644 --- a/public/locales/zh-CN.json +++ b/public/locales/zh-CN.json @@ -382,13 +382,22 @@ "storyFormats": "故事格式" }, "build": { - "proof": "校对", - "test": "测试", + "exportAsTwee": "导出为Twee", "play": "运行", - "publishToFile": "发布到文件" + "proof": "校对", + "publishToFile": "发布到文件", + "test": "测试" } }, "store": { + "archiveFilename": "{{timestamp}} Twine 档案.html", + "errors": { + "cantPersistPrefs": "保存偏好设置失败({{error}})。", + "cantPersistStories": "保存故事失败({{error}})。", + "cantPersistStoryFormats": "保存故事格式失败({{error}})。", + "electronRemediation": "重启应用可能会解决问题。", + "webRemediation": "重新载入该页面可能会解决问题。" + }, "passageDefaults": { "name": "未命名片段" }, @@ -397,18 +406,10 @@ }, "storyFormatDefaults": { "name": "未命名故事的格式" - }, - "errors": { - "cantPersistPrefs": "保存偏好设置失败({{error}})。", - "cantPersistStories": "保存故事失败({{error}})。", - "cantPersistStoryFormats": "保存故事格式失败({{error}})。", - "electronRemediation": "重启应用可能会解决问题。", - "webRemediation": "重新载入该页面可能会解决问题。" - }, - "archiveFilename": "{{timestamp}} Twine 档案.html" + } }, "undoChange": { - "replaceAllText": "替换所有", + "addTag": "添加标签", "changeTagColor": "更改标签颜色", "newPassage": "新建片段", "deletePassage": "删除片段", @@ -417,6 +418,8 @@ "movePassages": "移动片段", "imortTag": "移除标签", "renamePassage": "重命名片段", - "renameTag": "重命名标签" + "removeTag": "移除标签", + "renameTag": "重命名标签", + "replaceAllText": "替换所有" } -} \ No newline at end of file +} From 7c537efb07b1a093aa8a8270e7d44968f1ee016b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=AB=E4=B9=90=E7=9A=84=E8=80=81=E9=BC=A0=E5=AE=9D?= =?UTF-8?q?=E5=AE=9D?= Date: Thu, 24 Nov 2022 06:35:29 +0800 Subject: [PATCH 31/43] Add myself in translator credits list Also change language name to native name, not English. --- src/dialogs/about-twine/credits.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dialogs/about-twine/credits.json b/src/dialogs/about-twine/credits.json index 90ff9e2ff..2fe2329ac 100644 --- a/src/dialogs/about-twine/credits.json +++ b/src/dialogs/about-twine/credits.json @@ -26,6 +26,6 @@ "Mika Letonsaari (Suomi)", "Désirée Nordlund (Svenska)", "H. Utku Maden (Türkçe)", - "Shitake & SEN1 (Chinese)" + "Shitake & SEN1 & 快乐的老鼠宝宝 (简体中文)" ] } From 824ec94fe947dcee6e6cea4521b896d704449f8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=AB=E4=B9=90=E7=9A=84=E8=80=81=E9=BC=A0=E5=AE=9D?= =?UTF-8?q?=E5=AE=9D?= Date: Tue, 29 Nov 2022 01:56:42 +0800 Subject: [PATCH 32/43] fix: a redundant comma --- public/locales/zh-CN.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/locales/zh-CN.json b/public/locales/zh-CN.json index c6adddf45..d4508f661 100644 --- a/public/locales/zh-CN.json +++ b/public/locales/zh-CN.json @@ -268,7 +268,7 @@ "speech": "台词", "troubleshooting": "问题分析", "twineHelp": "Twine 帮助", - "view": "查看", + "view": "查看" }, "storiesDirectoryName": "故事", "updateCheck": { From 829bdff273b6b539b8e78ad3eca54a357022bb7f Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Mon, 28 Nov 2022 13:23:47 -0500 Subject: [PATCH 33/43] Fix CI hang --- .github/workflows/playwright.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 5792ce90f..660922e6e 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -19,7 +19,7 @@ jobs: - name: Install Playwright Browsers run: npx playwright install --with-deps - name: Run Playwright tests - run: npx playwright test + run: npx playwright test --reporter=line - uses: actions/upload-artifact@v3 if: always() with: From 272fabca4a030665dd85a796754c196dcf6442ac Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Mon, 28 Nov 2022 13:23:55 -0500 Subject: [PATCH 34/43] Use ref for center hook --- .../__tests__/use-view-center.test.tsx | 68 +++++++++++-------- src/routes/story-edit/story-edit-route.tsx | 4 +- src/routes/story-edit/use-view-center.ts | 25 ++++--- 3 files changed, 56 insertions(+), 41 deletions(-) diff --git a/src/routes/story-edit/__tests__/use-view-center.test.tsx b/src/routes/story-edit/__tests__/use-view-center.test.tsx index ce9567bb9..9e340be73 100644 --- a/src/routes/story-edit/__tests__/use-view-center.test.tsx +++ b/src/routes/story-edit/__tests__/use-view-center.test.tsx @@ -1,6 +1,7 @@ import {renderHook} from '@testing-library/react-hooks'; import {random} from 'faker'; -import {DialogsContext, DialogsContextProvider} from '../../../dialogs'; +import * as React from 'react'; +import {DialogsContext} from '../../../dialogs'; import {Story} from '../../../store/stories'; import {FakeStateProvider, fakeStory} from '../../../test-util'; import {useViewCenter} from '../use-view-center'; @@ -27,8 +28,10 @@ describe('useViewCenter', () => { }); describe('the getCenter() function it returns', () => { - it('returns the center of a DOM element, adjusted for the story zoom', () => { - const {result} = renderHook(() => useViewCenter(story, el as any)); + it('returns the center of a DOM element ref, adjusted for the story zoom', () => { + const {result} = renderHook(() => + useViewCenter(story, {current: el as any}) + ); expect(result.current.getCenter()).toEqual({ left: (el.scrollLeft + el.clientWidth / 2) / story.zoom, @@ -36,16 +39,18 @@ describe('useViewCenter', () => { }); }); - it('throws an error if the DOM element passed is null', () => { - const {result} = renderHook(() => useViewCenter(story, null)); + it('throws an error if the element ref is currently null', () => { + const {result} = renderHook(() => useViewCenter(story, {current: null})); expect(result.current.getCenter).toThrow(); }); }); describe('the setCenter() function it returns', () => { - it('scrolls the element to center a position, adjusted for the story zoom', () => { - const {result} = renderHook(() => useViewCenter(story, el as any)); + it('scrolls the element ref to center a position, adjusted for the story zoom', () => { + const {result} = renderHook(() => + useViewCenter(story, {current: el as any}) + ); const left = random.number(); const top = random.number(); @@ -62,27 +67,30 @@ describe('useViewCenter', () => { it('adjusts the center if dialogs are open', () => { const dialogWidth = random.number(); - const {result} = renderHook(() => useViewCenter(story, el as any), { - wrapper: ({children}) => ( - - null, - highlighted: false, - maximized: false - } - ] - }} - > - {children} - - - ) - }); + const {result} = renderHook( + () => useViewCenter(story, {current: el as any}), + { + wrapper: ({children}) => ( + + null, + highlighted: false, + maximized: false + } + ] + }} + > + {children} + + + ) + } + ); const left = random.number(); const top = random.number(); @@ -97,8 +105,8 @@ describe('useViewCenter', () => { ]); }); - it('throws an error if the element is null', () => { - const {result} = renderHook(() => useViewCenter(story, null)); + it('throws an error if the element ref is currently null', () => { + const {result} = renderHook(() => useViewCenter(story, {current: null})); expect(result.current.setCenter).toThrow(); }); diff --git a/src/routes/story-edit/story-edit-route.tsx b/src/routes/story-edit/story-edit-route.tsx index bdaad5733..50331d0ac 100644 --- a/src/routes/story-edit/story-edit-route.tsx +++ b/src/routes/story-edit/story-edit-route.tsx @@ -25,7 +25,7 @@ export const InnerStoryEditRoute: React.FC = () => { const story = storyWithId(stories, storyId); const [fuzzyFinderOpen, setFuzzyFinderOpen] = React.useState(false); const mainContent = React.useRef(null); - const {getCenter, setCenter} = useViewCenter(story, mainContent.current); + const {getCenter, setCenter} = useViewCenter(story, mainContent); const { handleDeselectPassage, handleDragPassages, @@ -73,7 +73,7 @@ export const InnerStoryEditRoute: React.FC = () => {
    ); -};; +};;;; // This is a separate component so that the inner one can use // `useDialogsContext()` and `useUndoableStoriesContext()` inside it. diff --git a/src/routes/story-edit/use-view-center.ts b/src/routes/story-edit/use-view-center.ts index dfd2a27fc..c1f27d940 100644 --- a/src/routes/story-edit/use-view-center.ts +++ b/src/routes/story-edit/use-view-center.ts @@ -4,32 +4,39 @@ import {usePrefsContext} from '../../store/prefs'; import {Story} from '../../store/stories'; import {Point} from '../../util/geometry'; -export function useViewCenter(story: Story, domElement: HTMLElement | null) { +export function useViewCenter( + story: Story, + elementRef: React.RefObject +) { const {dialogs} = useDialogsContext(); const {prefs} = usePrefsContext(); const getCenter = React.useCallback(() => { - if (!domElement) { + if (!elementRef.current) { throw new Error( 'Asked for the center of an element, but it does not exist in the DOM yet' ); } return { - left: (domElement.scrollLeft + domElement.clientWidth / 2) / story.zoom, - top: (domElement.scrollTop + domElement.clientHeight / 2) / story.zoom + left: + (elementRef.current.scrollLeft + elementRef.current.clientWidth / 2) / + story.zoom, + top: + (elementRef.current.scrollTop + elementRef.current.clientHeight / 2) / + story.zoom }; - }, [domElement, story.zoom]); + }, [elementRef, story.zoom]); const setCenter = React.useCallback( ({left, top}: Point) => { - if (!domElement) { + if (!elementRef.current) { throw new Error( 'Asked to set the center of an element, but it does not exist in the DOM yet' ); } - const {height, width} = domElement.getBoundingClientRect(); + const {height, width} = elementRef.current.getBoundingClientRect(); const scroll = { left: (left - width / story.zoom / 2) * story.zoom, top: (top - height / story.zoom / 2) * story.zoom @@ -39,9 +46,9 @@ export function useViewCenter(story: Story, domElement: HTMLElement | null) { scroll.left += prefs.dialogWidth / 2; } - domElement.scrollTo(scroll); + elementRef.current.scrollTo(scroll); }, - [dialogs.length, domElement, prefs.dialogWidth, story.zoom] + [dialogs.length, elementRef, prefs.dialogWidth, story.zoom] ); return {getCenter, setCenter}; From 6d0ca227d9b6c1ecc3a2c5e3b2706d95195a88ff Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Mon, 28 Nov 2022 14:32:15 -0500 Subject: [PATCH 35/43] Debounce passage fuzzy finding --- .../story-edit/passage-fuzzy-finder.tsx | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/routes/story-edit/passage-fuzzy-finder.tsx b/src/routes/story-edit/passage-fuzzy-finder.tsx index 93323ff2f..a3a5aa04b 100644 --- a/src/routes/story-edit/passage-fuzzy-finder.tsx +++ b/src/routes/story-edit/passage-fuzzy-finder.tsx @@ -1,3 +1,4 @@ +import {debounce} from 'lodash'; import * as React from 'react'; import {useHotkeys} from 'react-hotkeys-hook'; import {useTranslation} from 'react-i18next'; @@ -23,9 +24,21 @@ export const PassageFuzzyFinder: React.FC = props => { const {onClose, onOpen, open, setCenter, story} = props; const {dispatch} = useStoriesContext(); const [search, setSearch] = React.useState(''); + const [debouncedSearch, setDebouncedSearch] = React.useState(''); + const updateDebouncedSearch = React.useMemo( + () => + debounce( + (value: string) => { + setDebouncedSearch(value); + }, + 100, + {leading: true, trailing: true} + ), + [] + ); const matches = React.useMemo( - () => passagesMatchingFuzzySearch(story.passages, search), - [search, story.passages] + () => passagesMatchingFuzzySearch(story.passages, debouncedSearch), + [debouncedSearch, story.passages] ); const results = React.useMemo( () => @@ -38,6 +51,11 @@ export const PassageFuzzyFinder: React.FC = props => { useHotkeys('p', onOpen); const {t} = useTranslation(); + function handleChangeSearch(value: string) { + setSearch(value); + updateDebouncedSearch(value); + } + function handleSelectResult(index: number) { setCenter(matches[index]); dispatch(selectPassage(story, matches[index], true)); @@ -56,7 +74,7 @@ export const PassageFuzzyFinder: React.FC = props => { Date: Thu, 29 Dec 2022 19:11:01 +0100 Subject: [PATCH 36/43] Updated german translation to 2.6 version --- public/locales/de.json | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/public/locales/de.json b/public/locales/de.json index cb2394971..3489dc514 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -26,6 +26,7 @@ "editCount": "Bearbeitung ({{count}})", "help": "Hilfe", "import": "Import", + "maximize": "Maximieren", "more": "Mehr", "new": "Neu", "next": "Weiter", @@ -48,6 +49,7 @@ "twine": "Twine", "undo": "Rückgängig", "undoChange": "Rückgang {{change}}", + "unmaximize": "Größe zurücksetzen", "view": "Ansicht" }, "components": { @@ -86,9 +88,13 @@ "percentAvailable": "{percent}% Speicherplatz frei" }, "passageCard": { - "placeholderClick": "Doppelklicke auf diesen Abschnitt um ihn zu bearbeiten.", + "placeholderClick": "Doppelklick auf diesen Abschnitt um ihn zu bearbeiten.", "placeholderTouch": "Tippe auf den Abschnitt, dann gehe auf Bearbeiten im Abschnittstab um ihn zu bearbeiten." }, + "passageFuzzyFinder": { + "noResults": "Keine Treffer.", + "prompt": "Suche nach Abschnitts Name oder Text" + }, "renamePassageButton": { "emptyName": "Bitte gib einen Namen ein.", "nameAlreadyUsed": "Ein anderer Abschnitt dieser Geschichte hat diesen Namen." @@ -139,7 +145,7 @@ "aboutTwine": { "donateToTwine": "Unterstütze Twine mit einer Spende", "codeHeader": "Code", - "codeRepo": "Besuche Source Code Repository", + "codeRepo": "Besuche das Source Code Repository", "license": "Diese Anwendung ist unter der GPL v3 Lizenz veröffentlicht, aber ein Werk, das mit dieser Anwendung erstellt wurde, darf unter einer beliebigen auch kommerziellen Lizenz veröffentlicht werden.", "localizationHeader": "Lokalisierungen", "title": "Über Twine {{version}}", @@ -155,8 +161,14 @@ "appPrefs": { "codeEditorFont": "Code Editor Font", "codeEditorFontScale": "Code Editor Font Größe", + "dialogWidth": "Dialog Breite", + "dialogWidths": { + "default": "Default", + "wider": "Breiter", + "widest": "Am Breitesten" + }, "editorCursorBlinks": "Blinkender Cursor in den Editoren", - "fontExplanation": "Den Font hier zu ändern betrifft nur den Twine Editor. Es wird nicht den Font den eine Geschichte beim spielen benutzt ändern.", + "fontExplanation": "Font Änderungen an dieser Stelle betreffen nur den Twine Editor. Es wird nicht der Font den eine Geschichte beim Spielen benutzt ändern.", "language": "Sprache", "passageEditorFont": "Abschnitts Editor Font", "passageEditorFontScale": "Abschnitts Editor Font Größe", @@ -183,7 +195,8 @@ }, "storyImport": { "deselectAll": "Alle Markierungen aufheben", - "filePrompt": "Um Geschichten nach Twine zu importieren, lade unten entweder ein Archiv oder eine Datei einer veröffentlichen Geschichte hoch.", + "filePrompt": "Um Geschichten nach Twine zu importieren, lade unten entweder ein Archiv, eine Datei einer veröffentlichen Geschichte oder eine Twee Quelldatei hoch.", + "filePrompt": "To import stories into Twine, upload an archive, published story, or Twee source file below.", "importDifferentFile": "Importiere eine andere Datei", "importSelected": "Importiere die ausgewählten Dateien", "importThisStory": "Importiere Diese Geschichte", @@ -270,6 +283,7 @@ "storyEdit": { "toolbar": { "findAndReplace": "Suchen und Ersetzen", + "goTo": "Gehe Zu", "javaScript": "JavaScript", "passageTags": "Abschnitt Tags", "snapToGrid": "Am Raster ausrichten", @@ -369,6 +383,7 @@ "storyFormats": "Geschichtsformate" }, "build": { + "exportAsTwee": "Exportiere als Twee", "play": "Spiele", "proof": "Korrektur", "publishToFile": "Als Datei veröffentlichen", From d26824c96ac68fa39f0173b26e5419871f75a696 Mon Sep 17 00:00:00 2001 From: Leon Date: Sat, 7 Jan 2023 18:56:47 +1000 Subject: [PATCH 37/43] Updated Harlowe to 3.3.4. --- public/story-formats/harlowe-3.3.3/format.js | 4 ---- public/story-formats/harlowe-3.3.4/format.js | 4 ++++ .../story-formats/{harlowe-3.3.3 => harlowe-3.3.4}/icon.svg | 0 src/store/prefs/defaults.ts | 2 +- src/store/story-formats/defaults.ts | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) delete mode 100644 public/story-formats/harlowe-3.3.3/format.js create mode 100644 public/story-formats/harlowe-3.3.4/format.js rename public/story-formats/{harlowe-3.3.3 => harlowe-3.3.4}/icon.svg (100%) diff --git a/public/story-formats/harlowe-3.3.3/format.js b/public/story-formats/harlowe-3.3.3/format.js deleted file mode 100644 index b4b98b5e3..000000000 --- a/public/story-formats/harlowe-3.3.3/format.js +++ /dev/null @@ -1,4 +0,0 @@ -window.storyFormat({"name":"Harlowe","version":"3.3.3","author":"Leon Arnott","description":"The default story format for Twine 2, with numerous programming features and a rich passage editor. No HTML, JS or CSS experience required. Consult its documentation.","image":"icon.svg","url":"http://twinery.org/","license":"Zlib","proofing":false,"source":"\n\n\n\n\n{{STORY_NAME}}\n\n\n\n\n{{STORY_DATA}}\n\n\n\n\n","setup": function(){(function(){ -"use strict";function _createForOfIteratorHelper(a,t){var r,o="undefined"!=typeof Symbol&&a[Symbol.iterator]||a["@@iterator"];if(!o){if(Array.isArray(a)||(o=_unsupportedIterableToArray(a))||t&&a&&"number"==typeof a.length)return o&&(a=o),r=0,{s:t=function F(){},n:function n(){return r>=a.length?{done:!0}:{done:!1,value:a[r++]}},e:function e(a){throw a},f:t};throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var i,c=!0,l=!1;return{s:function s(){o=o.call(a)},n:function n(){var e=o.next();return c=e.done,e},e:function e(a){l=!0,i=a},f:function f(){try{c||null==o.return||o.return()}finally{if(l)throw i}}}}function _toArray(e){return _arrayWithHoles(e)||_iterableToArray(e)||_unsupportedIterableToArray(e)||_nonIterableRest()}function _slicedToArray(e,a){return _arrayWithHoles(e)||_iterableToArrayLimit(e,a)||_unsupportedIterableToArray(e,a)||_nonIterableRest()}function _nonIterableRest(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function _iterableToArrayLimit(e,a){var t=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null!=t){var r,o,n=[],i=!0,s=!1;try{for(t=t.call(e);!(i=(r=t.next()).done)&&(n.push(r.value),!a||n.length!==a);i=!0);}catch(e){s=!0,o=e}finally{try{i||null==t.return||t.return()}finally{if(s)throw o}}return n}}function _arrayWithHoles(e){if(Array.isArray(e))return e}function _toConsumableArray(e){return _arrayWithoutHoles(e)||_iterableToArray(e)||_unsupportedIterableToArray(e)||_nonIterableSpread()}function _nonIterableSpread(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function _unsupportedIterableToArray(e,a){if(e){if("string"==typeof e)return _arrayLikeToArray(e,a);var t=Object.prototype.toString.call(e).slice(8,-1);return"Map"===(t="Object"===t&&e.constructor?e.constructor.name:t)||"Set"===t?Array.from(e):"Arguments"===t||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t)?_arrayLikeToArray(e,a):void 0}}function _iterableToArray(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}function _arrayWithoutHoles(e){if(Array.isArray(e))return _arrayLikeToArray(e)}function _arrayLikeToArray(e,a){(null==a||a>e.length)&&(a=e.length);for(var t=0,r=new Array(a);t=n.length)&&!g.isFront)continue}s=this.end)return null;if(this.children.length)for(var a=0;a=this.end)return[];var a=[];if(this.children.length)for(var t=0;t=this.end?null:this.children?this.children.reduce(function(e,a){return e||(t>=a.start&&t|<=+|=+><=+|<==+>)"+o+l,l=o+"(=+\\|+|\\|+=+|=+\\|+=+|\\|=+\\|)"+o+l,p={opener:"\\[\\[(?!\\[)",text:"("+function notChars(){return"[^"+Array.apply(0,arguments).map(escape).join("")+"]*"}("]")+")",rightSeparator:a("\\->","\\|"),leftSeparator:"<\\-",closer:"\\]\\]",legacySeparator:"\\|",legacyText:"("+a("[^\\|\\]]","\\]"+t("\\]"))+"+)"},g=c+"*"+c.replace("\\w","a-zA-Z")+c+"*",b="\\$("+g+")",y="_("+g+")",f="'s"+n+"("+g+")",w="("+g+")"+n+"of"+i+t("it\\b"),k="'s"+n,v=a("it","time","turns?","visits?","exits?","pos")+i,x="its"+n+"("+g+")",T="("+g+")"+n+"of"+n+"it"+i,C="of"+n+"it"+i,S={opener:"\\(",name:"("+a("\\$","_")+"?"+s+"+):"+t("\\/"),closer:"\\)"},A=a("=<","=>","[gl]te?\\b","n?eq\\b","isnot\\b","are\\b","x\\b","isa\\b","or"+n+"a"+i),N="[a-zA-Z][\\w\\-]*",_="(?:\"[^\"]*\"|'[^']*'|[^'\">])*?",O="\\|("+s+"+)(>|\\))",L="(<|\\()("+s+"+)\\|",P="((?:\\b\\d+(?:\\.\\d+)?|\\.\\d+)(?:[eE][+\\-]?\\d+)?)"+t("m?s")+i;p.main=p.opener+a(p.text+p.rightSeparator,p.text.replace("*","*?")+p.leftSeparator)+p.text,e={upperLetter:"[A-Z\\u00c0-\\u00de\\u0150\\u0170]",lowerLetter:"[a-z0-9_\\-\\u00df-\\u00ff\\u0151\\u0171]",anyLetter:s,anyLetterStrict:c,whitespace:n.replace("[","[\\n\\r"),escapedLine:"\\\\\\n\\\\?|\\n\\\\",br:"\\n(?!\\\\)",tag:"<\\/?"+N+_+">",scriptStyleTag:"<("+a("script","style","textarea")+")"+_+">[^]*?<\\/\\1>",scriptStyleTagOpener:"<",url:"("+a("https?","mailto","javascript","ftp","data")+":\\/\\/[^\\s<]+[^<.,:;\"')\\]\\s])",bullet:"\\*",hr:m,heading:"[ \\f\\t\\v\\u00a0\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000]*(#{1,6})[ \\f\\t\\v\\u00a0\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000]*",align:u,column:l,bulleted:h,numbered:d,verbatimOpener:"`+",hookAppendedFront:"\\["+t("=+"),hookPrependedFront:O+"\\["+t("=+"),hookFront:"\\["+t("=+"),hookBack:"\\]"+t(L),hookAppendedBack:"\\]"+L,unclosedHook:"\\[=+",unclosedHookPrepended:O+"\\[=+",unclosedCollapsed:"\\{=+",passageLink:p.main+p.closer,legacyLink:p.opener+p.legacyText+p.legacySeparator+p.legacyText+p.closer,simpleLink:p.opener+p.legacyText+p.closer,macroFront:S.opener+r(S.name),macroName:S.name,groupingFront:"\\("+t(S.name),twine1Macro:"<<[^>\\s]+\\s*(?:\\\\.|'(?:[^'\\\\]*\\\\.)*[^'\\\\]*'|\"(?:[^\"\\\\]*\\\\.)*[^\"\\\\]*\"|[^'\"\\\\>]|>(?!>))*>>",validPropertyName:g,property:f,belongingProperty:w,possessiveOperator:k,belongingOperator:"of\\b",itsOperator:"its\\b",belongingItOperator:C,variable:b,tempVariable:y,hookName:"\\?("+s+"+)\\b",cssTime:"(\\d+\\.?\\d*|\\d*\\.?\\d+)(m?s)\\b",colour:a(a("Red","Orange","Yellow","Lime","Green","Cyan","Aqua","Blue","Navy","Purple","Fuchsia","Magenta","White","Gray","Grey","Black","Transparent"),"#[\\dA-Fa-f]{3}(?:[\\dA-Fa-f]{3})?"),datatype:a("alnum","alphanumeric","any(?:case)?","array","bool(?:ean)?","changer","codehook","colou?r","const","command","dm","data"+a("map","type","set"),"ds","digit","gradient","empty","even","int"+t("o")+"(?:eger)?","lambda","lowercase","macro","linebreak","newline","num(?:ber)?","odd","str(?:ing)?","uppercase","whitespace")+i,number:P,boolean:a("true","false")+i,identifier:v,itsProperty:x,belongingItProperty:T,escapedStringChar:"\\\\[^\\n]",singleStringOpener:"'",doubleStringOpener:'"',singleStringCloser:"'",doubleStringCloser:'"',is:"is"+t(n+"not"+i,n+"an?"+i,n+"in"+i,n+"<",n+">")+i,isNot:"is"+n+"not"+t(n+a("an?","in")+i)+i,isA:"is"+n+"an?"+i,isNotA:"is"+n+"not"+n+"an?"+i,matches:"matches\\b",doesNotMatch:"does"+n+"not"+n+"match"+i,and:"and\\b",or:"or\\b",not:"not\\b",inequality:"((?:is(?:"+n+"not)?"+o+")*)("+a("<(?!=)","<=",">(?!=)",">=")+")",isIn:"is"+n+"in"+i,contains:"contains\\b",doesNotContain:"does"+n+"not"+n+"contain"+i,isNotIn:"is"+n+"not"+n+"in"+i,addition:escape("+")+t("="),subtraction:escape("-")+t("=","type"),multiplication:escape("*")+t("="),division:a("/","%")+t("="),spread:"\\.\\.\\."+t("\\."),to:a("to\\b","="),into:"into\\b",making:"making\\b",where:"where\\b",when:"when\\b",via:"via\\b",each:"each\\b",augmentedAssign:a("\\+","\\-","\\*","\\/","%")+"=",bind:"2?bind\\b",typeSignature:escape("-type")+i,incorrectOperator:A,PlainCompare:{comma:",",commentFront:"\x3c!--",commentBack:"--\x3e",strikeOpener:"~~",italicOpener:"//",boldOpener:"''",supOpener:"^^",strongFront:"**",strongBack:"**",emFront:"*",emBack:"*",collapsedFront:"{",collapsedBack:"}",groupingBack:")"}},"object"===("undefined"==typeof module?"undefined":_typeof(module))?module.exports=e:"function"==typeof define&&define.amd?define("patterns",[],function(){return e}):this&&this.loaded?(this.modules||(this.modules={}),this.modules.Patterns=e):this.Patterns=e}.call(eval("this")||("undefined"!=typeof global?global:window)),!function(){Object.assign=Object.assign||function polyfilledAssign(e){for(var a=1;a<");return~t?25===(a=Math.round(t/(e.length-2)*50))&&(a="center"):"<"===e[0]&&">"===e.slice(-1)?a="justify":-1")?a="right":-1":">=","=<":"<=",gte:">=",lte:"<=",gt:">",lt:"<",eq:"is",isnot:"is not",neq:"is not",isa:"is a",are:"is",x:"*","or a":"or"}[e[0].toLowerCase().replace(/\s+/g," ")];return{type:"error",message:"Please say "+(a?"'"+a+"'":"something else")+" instead of '"+e[0]+"'.",explanation:"In the interests of readability, I want certain operators to be in a specific form."}},cannotFollowText:!0}},["boolean","is","to","into","where","when","via","making","each","and","or","not","isNot","contains","doesNotContain","isIn","isA","isNotA","isNotIn","matches","doesNotMatch","bind"].reduce(function(e,a){return e[a]={fn:t,cannotFollowText:!0},e},{}),["comma","spread","typeSignature","addition","subtraction","multiplication","division"].reduce(function(e,a){return e[a]={fn:t},e},{}))),h=setupRules(o,{singleStringCloser:l.singleStringOpener,doubleStringCloser:l.doubleStringOpener,escapedStringChar:l.escapedStringChar}),d=(r.push.apply(r,_toConsumableArray(u(n)).concat(_toConsumableArray(u(c)),_toConsumableArray(u(s)))),a.push.apply(a,_toConsumableArray(u(c)).concat(_toConsumableArray(u(l)))),o.push.apply(o,_toConsumableArray(u(h))),p({},n,s,c,l,h));return u(d).forEach(function(e){m.PlainCompare[e]?(d[e].pattern=m.PlainCompare[e],d[e].plainCompare=!0):d[e].pattern=RegExp("^(?:"+m[e]+")","i")}),p(e.rules,d),(s=e.modes).start=s.markup=r,s.macro=a,s.string=o,e}(e).lex,Patterns:m})}"object"===("undefined"==typeof module?"undefined":_typeof(module))?(m=require("./patterns"),module.exports=exporter(require("./lexer"))):"function"==typeof define&&define.amd?define("markup",["lexer","patterns"],function(e,a){return m=a,exporter(e)}):this&&this.loaded&&this.modules?(m=this.modules.Patterns,this.modules.Markup=exporter(this.modules.Lexer)):(m=this.Patterns,this.Markup=exporter(this.Lexer))}.call(eval("this")||("undefined"!=typeof global?global:window)),!function(){var a=Math.round,e=function insensitiveName(e){return(e+"").toLowerCase().replace(/-|_/g,"")},t={"#e61919":"red","#e68019":"orange","#e5e619":"yellow","#80e619":"lime","#19e619":"green","#19e5e6":"cyan","#197fe6":"blue","#1919e6":"navy","#7f19e6":"purple","#e619e5":"magenta","#ffffff":"white","#000000":"black","#888888":"grey"},r=function fontIcon(e){var a=1')},o=function GCD(e,a){return e?a?a
    ")),t.options.forEach(function(e){e=z("').concat(e,""));e[q]("change",update),n.append(e)})),"radios"===C&&(n=z('
    ").concat(t.name,"
")),t.options.forEach(function(e,a){a=z("").concat(e,""));a[q]("change",update),n.append(a)})),C.endsWith("textarea")&&(v="text",r=t.multiline?"textarea":"input",C.endsWith("expression-textarea")&&(t.update=function(e,a){!e.expression&&a[W](r).value?a.setAttribute("invalid","This doesn't seem to be valid code."):a.removeAttribute("invalid")},t.model=function(e,a){a=(a[W](r).value||"").trim();a&&(I(a,"","macro").children.every(function recur(e){return"text"!==e.type&&"error"!==e.type&&("string"===e.type||"hook"===e.type||e.children.every(recur))})?t.modelCallback(e,a):e.valid=!0)}),C.endsWith("string-textarea")&&!t.model&&(t.model=function(e,a){t.modelCallback(e,JSON.stringify(a[W](r).value||""))}),C.endsWith("number-textarea")&&(v="number",t.model||(t.model=function(e,a){t.modelCallback(e,+a[W](r).value||0)})),(n=z("<".concat(k?"span":"div",' class="harlowe-3-labeledInput">').concat(t.text,"<").concat(r," ").concat(t.useSelection?"data-use-selection":"").concat(C.includes("passage")?'list="harlowe-3-passages"':"",' style="width:').concat(t.width,";").concat(t.multiline?"max-width:".concat(t.width,";"):"","padding:var(--grid-size);margin").concat(k?":2px 0.5rem 0 0.5rem":"-left:1rem",";").concat(t.multiline&&k?"display:inline-block;height:40px":"",'" type=').concat(v,' placeholder="').concat(t.placeholder||"",'">")))[W](r)[q]("input",update)),(C.endsWith("number")||C.endsWith("range"))&&(n=z("<"+(k?"span":"div")+' class="harlowe-3-labeledInput">'+t.text+''+t.text+""),v=O(t.value),n.append(v),n[B]("input").forEach(function(e){return e[q]("change",update)})),C.endsWith("gradient")&&(i=(n=z("
")))[W](".harlowe-3-gradientBar"),a=function createColourStop(e,a,t){var r=z("
')+"
"),o=O(a),t=(o[B]("input").forEach(function(e){return e[q]("change",function(){var e=o[W]("[type=range]").value,a=o[W]("[type=color]").value;r.setAttribute("data-colour",F(a,e)),e<1&&r.setAttribute("data-harlowe-colour",M(a,e)),update()})}),z('")));t[q]("click",function(){r.remove(),update()}),o.append(t),r.firstChild.prepend(o),i.append(r),update()},setTimeout(function(){a(0,"#ffffff"),a(.5,"#000000",!0),a(1,"#ffffff")}),n[W]("button")[q]("click",function(){return a(.5,"#888888")}),n[q]("mousedown",v=function listener(e){var a,t,r,o,n=e.target;n.classList.contains("harlowe-3-colourStop")&&(a=document.documentElement,e=i.getBoundingClientRect(),t=e.left,r=e.right-t,e=function onMouseUp(){a[j]("mousemove",o),a[j]("mouseup",onMouseUp),a[j]("touchmove",o),a[j]("touchend",onMouseUp)},a[q]("mousemove",o=function onMouseMove(e){var a=e.pageX,e=e.touches;void 0!==(a=a||(null==e?void 0:e[0].pageX))&&(e=Math.min(1,Math.max(0,(a-window.scrollX-t)/r)),n.style.left="calc(".concat(100*e,"% - 8px)"),n.firstChild.style.left="".concat(-(R?464:384)*e-(R?0:40),"px"),n.setAttribute("data-pos",e),update())}),a[q]("mouseup",e),a[q]("touchmove",o),a[q]("touchend",e),Array.from(i[B]("[selected]")).forEach(function(e){return e.removeAttribute("selected")}),n.setAttribute("selected",!0))}),n[q]("touchstart",v),N.push(function(){i.style.background="linear-gradient(to right, ".concat(Array.from(i[B](".harlowe-3-colourStop")).sort(function(e,a){return e.getAttribute("data-pos")-a.getAttribute("data-pos")}).map(function(e){return e.getAttribute("data-colour")+" "+100*e.getAttribute("data-pos")+"%"}),")")})),C.endsWith("dropdown")&&(s=z("<"+(k?"span":"div")+' style="white-space:nowrap;'+(k?"":"width:50%;")+'position:relative;">'+t.text+'"),t.options.forEach(function(e,a){s[W]("select").append(z('