Skip to content

Commit 3228e9c

Browse files
authored
Support RTL flashcards specified by frontmatter "direction" attribute (#935)
* Nearly completed * Added RTL support for flashcards edit modal * Changes as part of the merge * post upstream master merge fixes * Minor code improvement * lint and format * Change log and documentation update * Minor code change * Fixed EditModal RTL * lint and format * Updated test cases to fix global coverage error * Format & lint
1 parent 971e4af commit 3228e9c

22 files changed

+324
-47
lines changed

docs/changelog.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ All notable changes to this project will be documented in this file. Dates are d
44

55
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
66

7-
[Unreleased]
7+
#### [Unreleased]
88

9+
- RTL support https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/335
910
- Fixed notes selection when all notes are reviewed. [`#548`](https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/548)
1011

1112
#### [1.12.4](https://github.com/st3v3nmw/obsidian-spaced-repetition/compare/1.12.3...1.12.4)

docs/en/flashcards.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,24 @@ The plugin will automatically search for folders that contain flashcards & use t
154154

155155
This is an alternative to the tagging option and can be enabled in settings.
156156

157+
## RTL Support
158+
159+
There are two ways that the plugin can be used with RTL languages, such as Arabic, Hebrew, Persian (Farsi).
160+
161+
If all cards are in a RTL language, then simply enable the global Obsidian option `Editor → Right-to-left (RTL)`.
162+
163+
If all cards within a single note have the same LTR/RTL direction, then frontmatter can be used to specify the text direction. For example:
164+
165+
```
166+
---
167+
direction: rtl
168+
---
169+
```
170+
171+
This is the same way text direction is specified to the `RTL Support` plugin.
172+
173+
Note that there is no current support for cards with different text directions within the same note.
174+
157175
## Reviewing
158176

159177
Once done creating cards, click on the flashcards button on the left ribbon to start reviewing the flashcards. After a card is reviewed, a HTML comment is added containing the next review day, the interval, and the card's ease.

src/NoteFileLoader.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Question } from "./Question";
44
import { TopicPath } from "./TopicPath";
55
import { NoteQuestionParser } from "./NoteQuestionParser";
66
import { SRSettings } from "./settings";
7+
import { TextDirection } from "./util/TextDirection";
78

89
export class NoteFileLoader {
910
fileText: string;
@@ -16,14 +17,19 @@ export class NoteFileLoader {
1617
this.settings = settings;
1718
}
1819

19-
async load(noteFile: ISRFile, folderTopicPath: TopicPath): Promise<Note | null> {
20+
async load(
21+
noteFile: ISRFile,
22+
defaultTextDirection: TextDirection,
23+
folderTopicPath: TopicPath,
24+
): Promise<Note | null> {
2025
this.noteFile = noteFile;
2126

2227
const questionParser: NoteQuestionParser = new NoteQuestionParser(this.settings);
2328

2429
const onlyKeepQuestionsWithTopicPath: boolean = true;
2530
const questionList: Question[] = await questionParser.createQuestionList(
2631
noteFile,
32+
defaultTextDirection,
2733
folderTopicPath,
2834
onlyKeepQuestionsWithTopicPath,
2935
);

src/NoteParser.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ISRFile } from "./SRFile";
33
import { Note } from "./Note";
44
import { SRSettings } from "./settings";
55
import { TopicPath } from "./TopicPath";
6+
import { TextDirection } from "./util/TextDirection";
67

78
export class NoteParser {
89
settings: SRSettings;
@@ -12,9 +13,18 @@ export class NoteParser {
1213
this.settings = settings;
1314
}
1415

15-
async parse(noteFile: ISRFile, folderTopicPath: TopicPath): Promise<Note> {
16+
async parse(
17+
noteFile: ISRFile,
18+
defaultTextDirection: TextDirection,
19+
folderTopicPath: TopicPath,
20+
): Promise<Note> {
1621
const questionParser: NoteQuestionParser = new NoteQuestionParser(this.settings);
17-
const questions = await questionParser.createQuestionList(noteFile, folderTopicPath, true);
22+
const questions = await questionParser.createQuestionList(
23+
noteFile,
24+
defaultTextDirection,
25+
folderTopicPath,
26+
true,
27+
);
1828

1929
const result: Note = new Note(noteFile, questions);
2030
return result;

src/NoteQuestionParser.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { CardFrontBack, CardFrontBackUtil } from "./QuestionType";
77
import { SRSettings, SettingsUtil } from "./settings";
88
import { ISRFile, frontmatterTagPseudoLineNum } from "./SRFile";
99
import { TopicPath, TopicPathList } from "./TopicPath";
10+
import { TextDirection } from "./util/TextDirection";
1011
import { extractFrontmatter, splitTextIntoLineArray } from "./util/utils";
1112

1213
export class NoteQuestionParser {
@@ -40,6 +41,7 @@ export class NoteQuestionParser {
4041

4142
async createQuestionList(
4243
noteFile: ISRFile,
44+
defaultTextDirection: TextDirection,
4345
folderTopicPath: TopicPath,
4446
onlyKeepQuestionsWithTopicPath: boolean,
4547
): Promise<Question[]> {
@@ -64,8 +66,11 @@ export class NoteQuestionParser {
6466
[this.frontmatterText, this.contentText] = extractFrontmatter(noteText);
6567

6668
// Create the question list
69+
let textDirection: TextDirection = noteFile.getTextDirection();
70+
if (textDirection == TextDirection.Unspecified) textDirection = defaultTextDirection;
6771
this.questionList = this.doCreateQuestionList(
6872
noteText,
73+
textDirection,
6974
folderTopicPath,
7075
this.tagCacheList,
7176
);
@@ -89,6 +94,7 @@ export class NoteQuestionParser {
8994

9095
private doCreateQuestionList(
9196
noteText: string,
97+
textDirection: TextDirection,
9298
folderTopicPath: TopicPath,
9399
tagCacheList: TagCache[],
94100
): Question[] {
@@ -100,7 +106,7 @@ export class NoteQuestionParser {
100106
const result: Question[] = [];
101107
const parsedQuestionInfoList: ParsedQuestionInfo[] = this.parseQuestions();
102108
for (const parsedQuestionInfo of parsedQuestionInfoList) {
103-
const question: Question = this.createQuestionObject(parsedQuestionInfo);
109+
const question: Question = this.createQuestionObject(parsedQuestionInfo, textDirection);
104110

105111
// Each rawCardText can turn into multiple CardFrontBack's (e.g. CardType.Cloze, CardType.SingleLineReversed)
106112
const cardFrontBackList: CardFrontBack[] = CardFrontBackUtil.expand(
@@ -144,14 +150,18 @@ export class NoteQuestionParser {
144150
return result;
145151
}
146152

147-
private createQuestionObject(parsedQuestionInfo: ParsedQuestionInfo): Question {
153+
private createQuestionObject(
154+
parsedQuestionInfo: ParsedQuestionInfo,
155+
textDirection: TextDirection,
156+
): Question {
148157
const questionContext: string[] = this.noteFile.getQuestionContext(
149158
parsedQuestionInfo.firstLineNum,
150159
);
151160
const result = Question.Create(
152161
this.settings,
153162
parsedQuestionInfo,
154163
null, // We haven't worked out the TopicPathList yet
164+
textDirection,
155165
questionContext,
156166
);
157167
return result;

src/Question.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { ParsedQuestionInfo } from "./parser";
1111
import { SRSettings } from "./settings";
1212
import { TopicPath, TopicPathList, TopicPathWithWs } from "./TopicPath";
1313
import { MultiLineTextFinder } from "./util/MultiLineTextFinder";
14+
import { TextDirection } from "./util/TextDirection";
1415
import { cyrb53, stringTrimStart } from "./util/utils";
1516

1617
export enum CardType {
@@ -87,6 +88,9 @@ export class QuestionText {
8788
// The question text, e.g. "Q1::A1" with leading/trailing whitespace as described above
8889
actualQuestion: string;
8990

91+
// Either LTR or RTL
92+
textDirection: TextDirection;
93+
9094
// The block identifier (optional), e.g. "^quote-of-the-day"
9195
// Format of block identifiers:
9296
// https://help.obsidian.md/Linking+notes+and+files/Internal+links#Link+to+a+block+in+a+note
@@ -102,11 +106,13 @@ export class QuestionText {
102106
original: string,
103107
topicPathWithWs: TopicPathWithWs,
104108
actualQuestion: string,
109+
textDirection: TextDirection,
105110
blockId: string,
106111
) {
107112
this.original = original;
108113
this.topicPathWithWs = topicPathWithWs;
109114
this.actualQuestion = actualQuestion;
115+
this.textDirection = textDirection;
110116
this.obsidianBlockId = blockId;
111117

112118
// The hash is generated based on the topic and question, explicitly not the schedule or obsidian block ID
@@ -117,10 +123,14 @@ export class QuestionText {
117123
return this.actualQuestion.endsWith("```");
118124
}
119125

120-
static create(original: string, settings: SRSettings): QuestionText {
126+
static create(
127+
original: string,
128+
textDirection: TextDirection,
129+
settings: SRSettings,
130+
): QuestionText {
121131
const [topicPathWithWs, actualQuestion, blockId] = this.splitText(original, settings);
122132

123-
return new QuestionText(original, topicPathWithWs, actualQuestion, blockId);
133+
return new QuestionText(original, topicPathWithWs, actualQuestion, textDirection, blockId);
124134
}
125135

126136
static splitText(original: string, settings: SRSettings): [TopicPathWithWs, string, string] {
@@ -264,7 +274,12 @@ export class Question {
264274

265275
let newText = MultiLineTextFinder.findAndReplace(noteText, originalText, replacementText);
266276
if (newText) {
267-
this.questionText = QuestionText.create(replacementText, settings);
277+
// Don't support changing the textDirection setting
278+
this.questionText = QuestionText.create(
279+
replacementText,
280+
this.questionText.textDirection,
281+
settings,
282+
);
268283
} else {
269284
console.error(
270285
`updateQuestionText: Text not found: ${originalText.substring(
@@ -293,10 +308,15 @@ export class Question {
293308
settings: SRSettings,
294309
parsedQuestionInfo: ParsedQuestionInfo,
295310
noteTopicPathList: TopicPathList,
311+
textDirection: TextDirection,
296312
context: string[],
297313
): Question {
298314
const hasEditLaterTag = parsedQuestionInfo.text.includes(settings.editLaterTag);
299-
const questionText: QuestionText = QuestionText.create(parsedQuestionInfo.text, settings);
315+
const questionText: QuestionText = QuestionText.create(
316+
parsedQuestionInfo.text,
317+
textDirection,
318+
settings,
319+
);
300320

301321
let topicPathList: TopicPathList = noteTopicPathList;
302322
if (questionText.topicPathWithWs) {

src/SRFile.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ import {
22
MetadataCache,
33
TFile,
44
Vault,
5-
HeadingCache,
65
getAllTags as ObsidianGetAllTags,
6+
HeadingCache,
77
TagCache,
88
FrontMatterCache,
99
} from "obsidian";
10+
import { TextDirection } from "./util/TextDirection";
1011
import { parseObsidianFrontmatterTag } from "./util/utils";
1112

1213
// NOTE: Line numbers are zero based
@@ -16,6 +17,7 @@ export interface ISRFile {
1617
getAllTagsFromCache(): string[];
1718
getAllTagsFromText(): TagCache[];
1819
getQuestionContext(cardLine: number): string[];
20+
getTextDirection(): TextDirection;
1921
read(): Promise<string>;
2022
write(content: string): Promise<void>;
2123
}
@@ -111,6 +113,22 @@ export class SrTFile implements ISRFile {
111113
return result;
112114
}
113115

116+
getTextDirection(): TextDirection {
117+
let result: TextDirection = TextDirection.Unspecified;
118+
const fileCache = this.metadataCache.getFileCache(this.file);
119+
const frontMatter = fileCache?.frontmatter;
120+
if (frontMatter && frontMatter?.direction) {
121+
// Don't know why the try/catch is needed; but copied from Obsidian RTL plug-in getFrontMatterDirection()
122+
try {
123+
const str: string = (frontMatter.direction + "").toLowerCase();
124+
result = str == "rtl" ? TextDirection.Rtl : TextDirection.Ltr;
125+
} catch (error) {
126+
// continue regardless of error
127+
}
128+
}
129+
return result;
130+
}
131+
114132
async read(): Promise<string> {
115133
return await this.vault.read(this.file);
116134
}

src/gui/EditModal.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { App, Modal } from "obsidian";
22
import { t } from "src/lang/helpers";
3+
import { TextDirection } from "src/util/TextDirection";
34

45
// from https://github.com/chhoumann/quickadd/blob/bce0b4cdac44b867854d6233796e3406dfd163c6/src/gui/GenericInputPrompt/GenericInputPrompt.ts#L5
56
export class FlashcardEditModal extends Modal {
@@ -17,17 +18,23 @@ export class FlashcardEditModal extends Modal {
1718
private rejectPromise: (reason?: any) => void;
1819
private didSaveChanges = false;
1920
private readonly modalText: string;
20-
21-
public static Prompt(app: App, placeholder: string): Promise<string> {
22-
const newPromptModal = new FlashcardEditModal(app, placeholder);
21+
private textDirection: TextDirection;
22+
23+
public static Prompt(
24+
app: App,
25+
placeholder: string,
26+
textDirection: TextDirection,
27+
): Promise<string> {
28+
const newPromptModal = new FlashcardEditModal(app, placeholder, textDirection);
2329
return newPromptModal.waitForClose;
2430
}
2531

26-
constructor(app: App, existingText: string) {
32+
constructor(app: App, existingText: string, textDirection: TextDirection) {
2733
super(app);
2834

2935
this.modalText = existingText;
3036
this.changedText = existingText;
37+
this.textDirection = textDirection;
3138

3239
this.waitForClose = new Promise<string>((resolve, reject) => {
3340
this.resolvePromise = resolve;
@@ -56,6 +63,9 @@ export class FlashcardEditModal extends Modal {
5663
this.textArea.addClass("sr-input");
5764
this.textArea.setText(this.modalText ?? "");
5865
this.textArea.addEventListener("keydown", this.saveOnEnterCallback);
66+
if (this.textDirection == TextDirection.Rtl) {
67+
this.textArea.setAttribute("dir", "rtl");
68+
}
5969

6070
this._createResponse(this.contentEl);
6171
}

src/gui/FlashcardModal.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,11 @@ export class FlashcardModal extends Modal {
120120
// Just the question/answer text; without any preceding topic tag
121121
const textPrompt = currentQ.questionText.actualQuestion;
122122

123-
const editModal = FlashcardEditModal.Prompt(this.app, textPrompt);
123+
const editModal = FlashcardEditModal.Prompt(
124+
this.app,
125+
textPrompt,
126+
currentQ.questionText.textDirection,
127+
);
124128
editModal
125129
.then(async (modifiedCardText) => {
126130
this.reviewSequencer.updateCurrentQuestionText(modifiedCardText);

src/gui/FlashcardReviewView.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,11 @@ export class FlashcardReviewView {
136136
this.plugin,
137137
this._currentNote.filePath,
138138
);
139-
await wrapper.renderMarkdownWrapper(this._currentCard.front, this.content);
139+
await wrapper.renderMarkdownWrapper(
140+
this._currentCard.front,
141+
this.content,
142+
this._currentQuestion.questionText.textDirection,
143+
);
140144
// Set scroll position back to top
141145
this.content.scrollTop = 0;
142146

@@ -292,7 +296,11 @@ export class FlashcardReviewView {
292296
this.plugin,
293297
this._currentNote.filePath,
294298
);
295-
wrapper.renderMarkdownWrapper(this._currentCard.back, this.content);
299+
wrapper.renderMarkdownWrapper(
300+
this._currentCard.back,
301+
this.content,
302+
this._currentQuestion.questionText.textDirection,
303+
);
296304

297305
// Show response buttons
298306
this.answerButton.addClass("sr-is-hidden");

0 commit comments

Comments
 (0)